diff --git a/FEATURES.md b/FEATURES.md index 80ce0479ea..8bf82fef69 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -45,6 +45,7 @@ graph TB PAY[Pay] BUY[Buy] COINS[Coins / UTXOs] + ANNOUNCEMENTS[Announcements] CONSOLIDATION[Consolidation] %% Dependencies to Core (all features depend on Core, but showing it explicitly would clutter the diagram) @@ -52,6 +53,8 @@ graph TB %% Feature-to-feature dependencies (extracted from draw.io diagram) ADDRESS_MGMT --> LABELS + ANNOUNCEMENTS --> SETTINGS + ANNOUNCEMENTS --> TX_HISTORY APP_STARTUP --> WALLETS AUTOSWAPS --> TRANSFER BIP85 --> SECRETS @@ -73,9 +76,12 @@ graph TB LABELS --> CORE PAY --> RECIPIENTS PAYJOIN --> UTXO_MGMT + PAYJOIN --> LABELS PIN_CODE --> CORE RECEIVE --> PAYJOIN + RECEIVE --> SETTINGS RECEIVE --> SWAPS + RECEIVE --> TX_HISTORY RECIPIENTS --> EXCHANGE SECRETS --> CORE SELL --> EXCHANGE @@ -84,6 +90,7 @@ graph TB SEND --> NETWORK SEND --> PAYJOIN SEND --> SWAPS + SEND --> TX_HISTORY SEND --> UTXO_MGMT SEND --> WALLETS SETTINGS --> CORE @@ -109,7 +116,7 @@ graph TB classDef featureStyle fill:#1a202c,stroke:#2d3748,stroke-width:2px,color:#e2e8f0 class CORE coreStyle - class SETTINGS,TOR,PIN_CODE,LABELS,SECRETS,HW_WALLETS,BTC_PRICE,NETWORK,BIP85,FEES,WALLETS,EXCHANGE,APP_STARTUP,UTXO_MGMT,ADDRESS_MGMT,RECIPIENTS,FUNDING,BACKUPS,SWAPS,PAYJOIN,WITHDRAWAL,STATUS,SEND,RECEIVE,TRANSFER,TX_HISTORY,BG_TASKS,AUTOSWAPS,DCA,SELL,PAY,BUY,COINS,CONSOLIDATION featureStyle + class SETTINGS,TOR,PIN_CODE,LABELS,SECRETS,HW_WALLETS,BTC_PRICE,NETWORK,BIP85,FEES,WALLETS,EXCHANGE,APP_STARTUP,UTXO_MGMT,ADDRESS_MGMT,RECIPIENTS,FUNDING,BACKUPS,SWAPS,PAYJOIN,WITHDRAWAL,STATUS,SEND,RECEIVE,TRANSFER,TX_HISTORY,BG_TASKS,AUTOSWAPS,DCA,SELL,PAY,BUY,COINS,ANNOUNCEMENTS,CONSOLIDATION featureStyle ``` ## About Package Dependency Diagrams diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index 019e2880f9..76d9c6ac2c 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -18,7 +18,6 @@ import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository 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/features/settings/domain/usecases/set_payjoin_enabled_usecase.dart'; import 'package:bb_mobile/locator.dart'; import 'package:bb_mobile/main.dart'; @@ -109,10 +108,6 @@ Future main({bool isInitialized = false}) async { setUpAll(() async { await locator().execute(Environment.testnet); - // Payjoin is disabled by default (opt-in) — this suite exercises the - // receive-with-payjoin usecase directly, so it must explicitly opt in, - // same as a real user would from the payjoin settings screen. - await locator().execute(true); // Drain any persisted payjoin state so the test starts clean. Ongoing // payjoins left behind by a previous (possibly crashed) run keep their @@ -249,8 +244,7 @@ Future main({bool isInitialized = false}) async { walletId: receiverWallet.id, address: address.address, ); - expect(payjoin, isNotNull, reason: 'payjoin is enabled in setUpAll'); - debugPrint('Payjoin receiver created: ${payjoin!.id}'); + debugPrint('Payjoin receiver created: ${payjoin.id}'); expect(payjoin.status, PayjoinStatus.started); // Check that the payjoin uri is correct @@ -336,8 +330,7 @@ Future main({bool isInitialized = false}) async { address: address.address, expireAfterSec: expireAfterSec, ); - expect(payjoin, isNotNull, reason: 'payjoin is enabled in setUpAll'); - debugPrint('Payjoin receiver created: ${payjoin!.id}'); + debugPrint('Payjoin receiver created: ${payjoin.id}'); final didReceiverExpire = await Future.any([ payjoinReceiverExpiredEvent.future, diff --git a/lib/core/payjoin/data/datasources/local_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/local_payjoin_datasource.dart index fcbfa57fac..54039af21c 100644 --- a/lib/core/payjoin/data/datasources/local_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/local_payjoin_datasource.dart @@ -58,8 +58,15 @@ class LocalPayjoinDatasource { Expression expr = const Constant(true); // identity if (onlyUnfinished) { + // isAborted is a terminal outcome too (we already broadcast the + // original in its place) — excluded here for the same reason + // isCompleted/isExpired are, otherwise an aborted session would + // keep being "resumed" on every app start. expr = - expr & row.isExpired.equals(false) & row.isCompleted.equals(false); + expr & + row.isExpired.equals(false) & + row.isCompleted.equals(false) & + row.isAborted.equals(false); } if (walletId != null) { @@ -78,7 +85,10 @@ class LocalPayjoinDatasource { if (onlyUnfinished) { expr = - expr & row.isExpired.equals(false) & row.isCompleted.equals(false); + expr & + row.isExpired.equals(false) & + row.isCompleted.equals(false) & + row.isAborted.equals(false); } if (walletId != null) { @@ -103,10 +113,24 @@ class LocalPayjoinDatasource { ]; } + /// Fetches the payjoin session(s) a transaction id belongs to, matching + /// BOTH the payjoin transaction id and the original transaction id. The + /// original matters as much as the payjoin one: an aborted session (we + /// broadcast the original instead of completing a real payjoin — see + /// PayjoinStatus.aborted) has no [txId] at all, so the transaction that + /// actually hit the chain IS the original — matching only [txId] made + /// that transaction's details lose its payjoin context entirely, hiding + /// the very "aborted" outcome the status exists to communicate. The + /// transactions LIST already joins on both ids + /// (GetTransactionsUsecase); this keeps the details path consistent. Future> fetchByTxId(String txId) async { final (receivers, senders) = await ( - _db.managers.payjoinReceivers.filter((f) => f.txId(txId)).get(), - _db.managers.payjoinSenders.filter((f) => f.txId(txId)).get(), + _db.managers.payjoinReceivers + .filter((f) => f.txId(txId) | f.originalTxId(txId)) + .get(), + _db.managers.payjoinSenders + .filter((f) => f.txId(txId) | f.originalTxId(txId)) + .get(), ).wait; return [ @@ -124,6 +148,7 @@ class LocalPayjoinDatasource { receivers = await receiversTable .filter((f) => f.isExpired(false)) .filter((f) => f.isCompleted(false)) + .filter((f) => f.isAborted(false)) .get(); } else { receivers = await receiversTable.get(); @@ -147,6 +172,7 @@ class LocalPayjoinDatasource { senders = await sendersTable .filter((f) => f.isExpired(false)) .filter((f) => f.isCompleted(false)) + .filter((f) => f.isAborted(false)) .get(); } else { senders = await sendersTable.get(); diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index b1a6513f20..b933e56b78 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -5,6 +5,7 @@ import 'dart:developer'; import 'package:bb_mobile/core/errors/bull_exception.dart'; import 'package:bb_mobile/core/payjoin/data/models/payjoin_input_pair_model.dart'; import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart' show Payjoin; 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; @@ -43,6 +44,8 @@ class PdkPayjoinDatasource { final Set _receiverPollsInFlight = {}; final Set _senderPollsInFlight = {}; + bool _disposed = false; + PdkPayjoinDatasource({ this._payjoinDirectoryUrl = PayjoinConstants.directoryUrl, required this._dio, @@ -60,6 +63,46 @@ class PdkPayjoinDatasource { Stream get expiredPayjoins => _expiredController.stream; + /// Stops the directory polling of one session — both the receiver + /// request poll and the sender proposal poll, whichever exists for + /// [payjoinId]. Called by the repository the moment a session resolves + /// through a path the poll itself can't see (the plain-broadcast fallback + /// landing on-chain): the poll only self-cancels on request/proposal + /// found or expiry, so without this it kept firing until expiry and then + /// raised a stale expired event for an already-completed session + /// (observed live: a redundant second broadcast of the original + /// transaction a minute after the session had already resolved). + void stopPolling(String payjoinId) { + _receiverTimers.remove(payjoinId)?.cancel(); + _senderTimers.remove(payjoinId)?.cancel(); + } + + /// Cancels every polling timer and closes the event streams. Individual + /// poll timers self-cancel on success/expiry, but a session that never + /// resolves (a relay permanently down) would otherwise leave a + /// [Timer.periodic] firing forever plus three unclosed broadcast + /// controllers. The production singleton lives for the whole app session, + /// but tests (and any future teardown) need a clean exit; the repository's + /// own dispose delegates here. Idempotent: a second call is a no-op (closing + /// an already-closed controller would otherwise throw). + Future dispose() async { + if (_disposed) return; + _disposed = true; + for (final timer in _receiverTimers.values) { + timer.cancel(); + } + _receiverTimers.clear(); + for (final timer in _senderTimers.values) { + timer.cancel(); + } + _senderTimers.clear(); + _receiverPollsInFlight.clear(); + _senderPollsInFlight.clear(); + await _payjoinRequestedController.close(); + await _proposalSentController.close(); + await _expiredController.close(); + } + Future<(OhttpKeys?, String?)> fetchOhttpKeyAndRelay({ required String payjoinDirectory, }) async { @@ -274,6 +317,57 @@ class PdkPayjoinDatasource { return updatedModel; } + /// Formally cancels a receiver session that was declined below the + /// configured minimum-receive-amount threshold (see + /// PayjoinRepositoryImpl._processPayjoinRequest), and closes the + /// underlying PDK session so it persists a terminal event. + /// + /// This replaces silently abandoning the session after broadcasting the + /// original transaction out of band: without this, the PDK's own + /// typestate machine never learns the session ended, so only our local + /// DB flag (isAborted) stood between it and being replayed/resumed as if + /// still pending. `cancel()` is available on every receive typestate that + /// carries a fallback transaction (verified against the installed + /// `payjoin` package's Dart bindings — `MaybeInputsOwned.cancel()` is one + /// of them); calling it here transitions to `ReceiverPendingFallback`, + /// whose `close()` persists the closing `SessionEvent` via the + /// persister. The original transaction itself is still broadcast by the + /// caller from the already-captured, already-validated + /// [PayjoinReceiverModel.originalTxBytes] — this method only concludes + /// the PDK-side state machine to match that outcome. + /// + /// Always called right after `_pollReceiverOnce` has persisted a session + /// at exactly the `MaybeInputsOwned` typestate (where + /// `originalTxBytes`/`amountSat` first become available) — any other + /// state means the session already progressed past the point a + /// below-minimum decline is possible, or is already resolved. + String declineReceiverSession(PayjoinReceiverModel receiverModel) { + final persister = InMemoryJsonReceiverSessionPersister.fromJson( + receiverModel.receiver, + ); + final state = replayReceiverEventLog(persister: persister).state(); + if (state is! MaybeInputsOwnedReceiveSession) { + throw StateError( + 'Cannot decline payjoin receiver ${receiverModel.id}: expected a ' + 'MaybeInputsOwned session, got $state', + ); + } + + final pendingFallback = state.inner.cancel().save(persister: persister); + if (pendingFallback == null) { + // The session was already terminal (e.g. a race with another decline + // path) — nothing further to persist, but not an error either. + logger.log.info( + 'Payjoin receiver ${receiverModel.id} was already resolved when ' + 'declining below minimum', + ); + return persister.toJson(); + } + + pendingFallback.close().save(persister: persister); + return persister.toJson(); + } + Future<({Monitor monitor, String psbt})> processReceiveSession({ required ReceiveSession state, required InMemoryJsonReceiverSessionPersister persister, @@ -688,14 +782,18 @@ class PdkPayjoinDatasource { PayjoinSenderModel senderModel, Timer timer, ) async { + // logRef, never the raw id in log lines/exception messages: a sender id + // is the full BIP21 URI (address+amount+endpoint). The raw id is still + // used as the internal map key below, which never reaches a log. + final senderLogRef = Payjoin.logRefForId(senderModel.id); if (!_senderPollsInFlight.add(senderModel.id)) return; - log('[sender poll] checking for proposal for ${senderModel.id}'); + log('[sender poll] checking for proposal for $senderLogRef'); 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', + 'Payjoin sender $senderLogRef expiry time passed', ); } final persister = InMemoryJsonSenderSessionPersister.fromJson( @@ -715,7 +813,7 @@ class PdkPayjoinDatasource { final proposalPsbt = await _getProposalPsbt(state.inner, persister); if (proposalPsbt == null) return; - log('[sender poll] proposal found for ${senderModel.id}'); + log('[sender poll] proposal found for $senderLogRef'); final txId = (await BitcoinTx.fromPsbt(proposalPsbt)).txid; final updatedModel = senderModel.copyWith( sender: persister.toJson(), @@ -730,13 +828,13 @@ class PdkPayjoinDatasource { _senderTimers.remove(senderModel.id); _proposalSentController.add(updatedModel); } on PayjoinExpiredException catch (e) { - logger.log.info('[sender poll] expired for ${senderModel.id}: $e'); + logger.log.info('[sender poll] expired for $senderLogRef: $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'); + logger.log.info('[sender poll] $senderLogRef: $e'); } finally { _senderPollsInFlight.remove(senderModel.id); } diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index 415220c690..a576bb2404 100644 --- a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart +++ b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart @@ -12,6 +12,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/seed/data/datasources/seed_datasource.dart'; import 'package:bb_mobile/core/seed/data/models/seed_model.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; 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; @@ -21,7 +22,12 @@ import 'package:bb_mobile/core/wallet/data/datasources/wallet_metadata_datasourc import 'package:bb_mobile/core/wallet/data/models/wallet_metadata_model.dart'; import 'package:bb_mobile/core/wallet/data/models/wallet_model.dart'; import 'package:bb_mobile/core/wallet/data/models/wallet_utxo_model.dart'; +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/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; +import 'package:meta/meta.dart'; import 'package:synchronized/synchronized.dart'; class PayjoinRepositoryImpl implements PayjoinRepository { @@ -32,11 +38,65 @@ class PayjoinRepositoryImpl implements PayjoinRepository { final BdkWalletDatasource _bdkWallet; final BdkBitcoinBlockchainDatasource _blockchain; final ElectrumServersPort _serversPort; + // Wallet repositories are resolved lazily (via closures, not injected + // instances) because this repository is an eager singleton constructed + // BEFORE WalletLocator registers them (see core_locator.dart ordering). + // They are only ever called well after startup, from broadcast / + // proposal / completion handlers. + final WalletRepository Function() _walletRepository; + final WalletTransactionRepository Function() _walletTransactionRepository; + final SettingsRepository _settingsRepository; + // Lazy accessor, not a direct LabelsFacade — matches the existing + // core/exchange and core/swaps precedent of injecting LabelsFacade + // straight into core code that needs to label something (see + // LabelExchangeOrdersUsecase, AutoSwapExecutionUsecase). The indirection + // here is purely a registration-order workaround, not an architectural + // boundary: PayjoinRepositoryImpl is an eager registerSingleton built + // during PayjoinLocator.registerRepositories, which runs before + // LabelsLocator.registerFacade (see core_locator.dart) — resolving + // LabelsFacade eagerly at construction time would throw. + final LabelsFacade Function() _labelsFacade; // Lock to prevent the same utxo from being used in multiple payjoin proposals final Lock _lock; final StreamController _payjoinStreamController; + // Per-session subscriptions watching for a completed payjoin transaction to + // appear on-chain, keyed by payjoin id. Kept so each can be cancelled once + // its transaction is seen (the underlying watch stream re-emits on every + // sync), on expiry, or on teardown. This is the only long-lived + // subscription state in this otherwise fire-and-forget singleton, so its + // hygiene lives entirely in _watchForBroadcast / _stopWatching / dispose. + final Map> _broadcastWatchers = {}; + + // Per-session active-poll timers complementing _broadcastWatchers: each one + // periodically forces a sync'd wallet-transaction lookup so completion does + // not depend on some unrelated wallet sync happening to run (see + // _watchForBroadcast). Keyed by payjoin id, cancelled together with the + // passive watcher in _stopWatching / dispose. + final Map _broadcastPollTimers = {}; + + // Mirrors _broadcastWatchers/_broadcastPollTimers but for the ORIGINAL + // transaction instead of the real payjoin one — the safety net for + // "the counterparty fell back independently and we'd otherwise never find + // out" (see _watchForFallback). Armed as soon as originalTxId is known + // (session creation for a sender, request-received for a receiver) and + // runs alongside any later _watchForBroadcast for the same session: + // whichever of the two lands on-chain first resolves the session, and + // _stopWatching tears down both together. + final Map> _fallbackWatchers = {}; + final Map _fallbackPollTimers = {}; + + // Datasource stream subscriptions, cancelled on dispose. + final List> _datasourceSubscriptions = []; + + /// Delay between the automatic original-broadcast fallback retries (see + /// [_broadcastOriginalWithRetry]). Mutable only so tests can zero it out to + /// avoid real-time waits (and the timer bleed they cause across tests); + /// production keeps the 1s default. + @visibleForTesting + Duration fallbackRetryDelay = const Duration(seconds: 1); + PayjoinRepositoryImpl({ required this._localPayjoinDatasource, required this._pdkPayjoinDatasource, @@ -45,19 +105,63 @@ class PayjoinRepositoryImpl implements PayjoinRepository { required BdkWalletDatasource bdkWalletDatasource, required BdkBitcoinBlockchainDatasource blockchainDatasource, required this._serversPort, + required this._walletRepository, + required this._walletTransactionRepository, + required this._settingsRepository, + required this._labelsFacade, }) : _seed = seedDatasource, _bdkWallet = bdkWalletDatasource, _blockchain = blockchainDatasource, _lock = Lock(), _payjoinStreamController = StreamController.broadcast() { // Listen to payjoin events from the datasource and process them - _pdkPayjoinDatasource.requestsForReceivers.listen(_processPayjoinRequest); - _pdkPayjoinDatasource.proposalsForSenders.listen(_processPayjoinProposal); - _pdkPayjoinDatasource.expiredPayjoins.listen(_processExpiredPayjoin); + _datasourceSubscriptions.addAll([ + _pdkPayjoinDatasource.requestsForReceivers.listen(_processPayjoinRequest), + _pdkPayjoinDatasource.proposalsForSenders.listen(_processPayjoinProposal), + _pdkPayjoinDatasource.expiredPayjoins.listen(_processExpiredPayjoin), + ]); - // Now that the listeners are set up, we can resume processing of possible - // ongoing payjoins. - _resumePayjoins(); + // Deliberately NOT resuming here: this is an eager singleton constructed + // before WalletLocator / the labels facade register their dependencies + // (see core_locator.dart ordering). A resumed session that reaches + // _watchForBroadcast or the labels facade before those are registered + // would throw inside this unawaited constructor call, silently aborting + // resume for every remaining session. resumePayjoinsOnStartup() is + // called explicitly by AppLocator.setup once every core dependency it + // needs (wallet repositories, settings, the labels facade) is + // registered. + } + + /// Releases all long-lived subscriptions and closes the payjoin stream. + /// The production singleton lives for the whole app session and is never + /// disposed, but tests (and any future teardown) need a clean exit. + /// + /// Also tears down the datasource so its per-session polling timers and + /// event controllers don't outlive this repository — this repository owns + /// the datasource's lifecycle (it's the sole subscriber to its streams). + Future dispose() async { + for (final timer in _broadcastPollTimers.values) { + timer.cancel(); + } + _broadcastPollTimers.clear(); + for (final sub in _broadcastWatchers.values) { + await sub.cancel(); + } + _broadcastWatchers.clear(); + for (final timer in _fallbackPollTimers.values) { + timer.cancel(); + } + _fallbackPollTimers.clear(); + for (final sub in _fallbackWatchers.values) { + await sub.cancel(); + } + _fallbackWatchers.clear(); + for (final sub in _datasourceSubscriptions) { + await sub.cancel(); + } + _datasourceSubscriptions.clear(); + await _pdkPayjoinDatasource.dispose(); + await _payjoinStreamController.close(); } @override @@ -201,6 +305,16 @@ class PayjoinRepositoryImpl implements PayjoinRepository { // Store the payjoin sender in the local database await _localPayjoinDatasource.storeSender(model); + // Arm the fallback safety net now: the receiver could decline + // below-minimum (or its own session could expire) and broadcast the + // original transaction well before this sender's own session would + // otherwise notice (see _watchForFallback's doc comment). + _watchForFallback( + payjoinId: model.id, + walletId: model.walletId, + originalTxId: model.originalTxId, + ); + // Return a payjoin entity with send details final payjoin = model.toEntity(); @@ -209,6 +323,63 @@ class PayjoinRepositoryImpl implements PayjoinRepository { @override Future tryBroadcastOriginalTransaction(Payjoin payjoin) async { + // Idempotency/safety guard for MANUAL/external callers only (the + // BroadcastOriginalTransactionUsecase invoked from + // ReceiveBloc._onPayjoinOriginalTxBroadcasted and + // TransactionDetailsCubit.broadcastPayjoinOriginalTx) — re-checked + // against the freshest persisted state rather than trusting the + // caller's possibly-stale copy, using the SAME canonical + // Payjoin.canManuallyBroadcastOriginal getter those buttons' visibility + // is gated on, so this can never disagree with what the UI decided to + // show. Every one of those UI call sites SHOULD already gate on this + // themselves, but this is cheap insurance against a stale UI snapshot + // letting a tap through anyway — observed live: a sender's + // already-completed-via-fallback session got a second "Send without + // payjoin" tap ~10s later, re-broadcasting the same original psbt + // (harmless here only because it was byte-for-byte identical to what + // already confirmed). Had a REAL payjoin completed instead, this would + // have re-broadcast a lower-fee transaction competing for the same + // inputs as the already-broadcast payjoin tx — the exact dangerous RBF + // race this guard exists to prevent. + // + // Deliberately NOT applied to this repository's own INTERNAL fallback + // calls (_processPayjoinRequest, _processPayjoinProposal, + // _processExpiredPayjoin all call _broadcastOriginalTransaction + // directly, bypassing this): those run precisely WHILE the freshly + // persisted model still has a proposal "in flight" by definition (that + // proposal having just failed is why they are falling back at all), so + // this guard would otherwise block its own legitimate fallback attempt. + final freshModel = payjoin is PayjoinReceiver + ? await _localPayjoinDatasource.fetchReceiver(payjoin.id) + : await _localPayjoinDatasource.fetchSender(payjoin.id); + if (freshModel != null) { + final freshEntity = freshModel.toEntity(); + if (!freshEntity.canManuallyBroadcastOriginal) { + log.warning( + 'tryBroadcastOriginalTransaction ignored for ${payjoin.logRef}: ' + 'already completed or a proposal is in flight', + ); + return freshEntity; + } + } + + final result = await _broadcastOriginalTransaction(payjoin); + // Emit on the stream so OTHER live watchers of this same session (e.g. a + // transaction-details screen open alongside the receive screen) learn of + // the abort without waiting for a reload. Internal callers keep the + // "caller emits" contract and add the result themselves; only this + // public, UI-triggered entry point has no caller downstream to do it. + if (result != null) { + _payjoinStreamController.add(result); + } + return result; + } + + /// The actual original-transaction broadcast mechanism, shared by the + /// public (guarded) [tryBroadcastOriginalTransaction] entry point and this + /// repository's own internal fallback call sites, which intentionally + /// bypass that guard (see its doc comment for why). + Future _broadcastOriginalTransaction(Payjoin payjoin) async { try { final network = ElectrumServerNetwork.fromEnvironment( isTestnet: payjoin.isTestnet, @@ -237,19 +408,76 @@ class PayjoinRepositoryImpl implements PayjoinRepository { ); model = await _localPayjoinDatasource.fetchSender(payjoin.id); } + // logRef, never id/raw txid: a sender payjoin id is the full BIP21 + // URI and a raw txid identifies the payment on-chain — both off-limits + // in logs (user-shareable / pasted into issues). log.info( - 'Original transaction broadcasted: ${payjoin.id} with txId: ${payjoin.originalTxId}', + 'Original transaction broadcasted for payjoin ${payjoin.logRef}', ); - // Update the local database with the completed payjoin - if (model == null) { throw Exception('Payjoin not found locally'); } - final completedModel = model.copyWith(isCompleted: true); - await _localPayjoinDatasource.update(completedModel); + // This is a fallback broadcast, not a real payjoin: mark the session + // aborted (not completed) so the true outcome survives in the + // transaction history — see PayjoinStatus.aborted. + // + // txId is explicitly reset: this session is completed by the ORIGINAL + // transaction, so any txId persisted earlier refers to a payjoin + // transaction that never reached the chain. A sender's txId is set the + // moment a proposal is RECEIVED (before signing/broadcasting), so a + // proposal that later fails to sign/broadcast would otherwise leave a + // stale txId behind — and SendCubit prefers txId over originalTxId for + // the success screen and the final tx label, surfacing (and labelling) + // a txid that was never broadcast. This clearing is display hygiene, + // not status derivation: the status comes from the explicit isAborted + // flag (see PayjoinModel.status). + final abortedModel = model.copyWith(isAborted: true, txId: null); + await _localPayjoinDatasource.update(abortedModel); + // Deliberately NOT labelling this transaction "payjoin": every call + // site of this method is, by definition, a case where no real payjoin + // ever happened — declined below the anti-probing minimum, the + // negotiation failed, or the session expired before a proposal was + // exchanged. The tx that lands here is byte-for-byte the caller's own + // plain, single-party transaction; slapping a "payjoin" label on it + // would be misleading. Only _onPayjoinTransactionSeen and _broadcastPsbt + // label a transaction — both reachable exclusively once a real proposal + // was exchanged. + // + // Stop watching now that the session is resolved via this path: the + // fallback watch armed at request-received (receiver) or session + // creation (sender) would otherwise keep doing a wallet-transaction + // lookup on every sync for the rest of the app's lifetime. Defensive + // and idempotent — a no-op when no watcher is registered. (Re-armed + // just below to confirm the tx we broadcast actually lands locally.) + _stopWatching(payjoin.id); + + // Best-effort, non-blocking: get the wallet's balance/tx list to + // reflect this broadcast promptly instead of waiting on whatever + // unrelated sync happens to run next (the same staleness class of gap + // as the receiver's own payjoin-tx watcher — see _watchForBroadcast). + _syncWalletAfterBroadcast(abortedModel.walletId); + + // That single sync is not enough on its own: it can be throttled by + // the sync coordinator or race the broadcast (observed live: a + // receiver's below-minimum fallback stayed invisible in its own + // wallet until several manual resyncs). Re-arm the original-tx watch + // so its backoff poll keeps forcing DIRECT electrum-backed lookups + // (getWalletTransaction(sync: true) bypasses the coordinator) until + // the transaction we just broadcast actually lands in the local + // wallet database. It tears itself down the moment the tx is seen: + // _onOriginalTransactionSeen stops all watchers first and its + // terminal guard makes it a pure teardown for this already-persisted + // session. + if (abortedModel.originalTxId != null) { + _watchForFallback( + payjoinId: payjoin.id, + walletId: abortedModel.walletId, + originalTxId: abortedModel.originalTxId!, + ); + } - return completedModel.toEntity(); + return abortedModel.toEntity(); } catch (e) { log.severe( message: 'Error broadcasting original transaction', @@ -261,21 +489,66 @@ class PayjoinRepositoryImpl implements PayjoinRepository { } Future _processPayjoinRequest(PayjoinReceiverModel model) async { - log.info('Processing payjoin request: ${model.id}'); - // Update the local database with the new payjoin request - await _localPayjoinDatasource.update(model); + // The whole handler is inside this try/catch, including the initial DB + // update and stream emit below: a fallible await left outside a try (as + // this used to be) can throw straight out of the datasource's + // stream .listen() callback as an unhandled async error — with the + // directory poll already cancelled by the time this fires, no expiry + // event would ever arrive to unstick it either, stranding the session + // in "requested" until the app restarts. + PayjoinReceiver? result; + try { + log.info('Processing payjoin request: ${model.id}'); + await _localPayjoinDatasource.update(model); - final payjoin = model.toEntity() as PayjoinReceiver; + final payjoin = model.toEntity() as PayjoinReceiver; - // Notify higher layers that a new payjoin request was received - _payjoinStreamController.add(payjoin); + // Notify higher layers that a new payjoin request was received + _payjoinStreamController.add(payjoin); - // Now try to process the request - PayjoinReceiver? result; - try { - final wallet = await _loadWallet(model.walletId); - final unspentUtxos = await _bdkWallet.getUtxos(wallet: wallet); - result = await _proposePayjoin(model, wallet, unspentUtxos); + // Arm the fallback safety net now that originalTxId is known: from + // here on, EITHER side could end up broadcasting the original + // transaction (this receiver declining below-minimum in a moment, a + // failed negotiation, or either session's own expiry), and this is + // the only way this side finds out if it was the OTHER one that did + // it (see _watchForFallback's doc comment). Left running until the + // session actually resolves, including through the negotiation + // attempted below. + if (model.originalTxId != null) { + _watchForFallback( + payjoinId: model.id, + walletId: model.walletId, + originalTxId: model.originalTxId!, + ); + } + + // Anti-probing minimum-value policy (BIP78): declining below this + // threshold costs the sender nothing extra (they're still paid + // normally via the original transaction) while raising the cost of + // probing our UTXO set to at least a real payment above the + // threshold. See PayjoinConstants.defaultMinAmountSat. + final settings = await _settingsRepository.fetch(); + if (isBelowPayjoinMinimum( + amountSat: model.amountSat, + minAmountSat: settings.payjoinMinAmountSat, + )) { + log.warning( + 'Payjoin request ${model.id} below minimum ' + '(${settings.payjoinMinAmountSat} sat); declining and ' + 'broadcasting original instead', + ); + final declinedReceiverJson = _pdkPayjoinDatasource + .declineReceiverSession(model); + final declinedModel = model.copyWith(receiver: declinedReceiverJson); + await _localPayjoinDatasource.update(declinedModel); + result = + (await _broadcastOriginalWithRetry(declinedModel.toEntity())) + as PayjoinReceiver?; + } else { + final wallet = await _loadWallet(model.walletId); + final unspentUtxos = await _bdkWallet.getUtxos(wallet: wallet); + result = await _proposePayjoin(model, wallet, unspentUtxos); + } } catch (e) { log.severe( message: 'Error processing payjoin request', @@ -283,15 +556,96 @@ class PayjoinRepositoryImpl implements PayjoinRepository { trace: StackTrace.current, ); result = - (await tryBroadcastOriginalTransaction(payjoin)) as PayjoinReceiver?; + (await _broadcastOriginalWithRetry(model.toEntity())) + as PayjoinReceiver?; } if (result != null) { _payjoinStreamController.add(result); + // A proposal was sent: from here the sender finalizes and broadcasts + // the payjoin transaction. Watch for that txid to land on-chain so we + // can mark the receiver session completed and label the transaction. + if (result.proposalPsbt != null && result.txId != null) { + _watchForBroadcast( + payjoinId: result.id, + walletId: result.walletId, + txId: result.txId!, + ); + } + } + } + + /// [_broadcastOriginalTransaction] with a small bounded retry, for the + /// automatic fallback paths (below-minimum decline and request-processing + /// failure). Those paths run exactly once per request event: the + /// directory poll was already cancelled when the request was emitted, so + /// no expiry event will fire either — a single transient Electrum blip + /// would otherwise leave the session with no further automatic attempt + /// this run. If every attempt fails, this logs SEVERE and returns null + /// ON PURPOSE without marking the session terminal: it stays unfinished + /// in the DB, so the next app start replays it through + /// _processPayjoinRequest and retries (the decline path then throws + /// StateError on the already-cancelled PDK session, which lands in the + /// same catch → broadcast fallback). In the meantime the receive screen's + /// manual "receive payment normally" button remains available and + /// surfaces its own errors. + /// + /// Uses [_broadcastOriginalTransaction] directly (not the guarded public + /// entry point): this is an internal fallback that runs precisely while + /// the persisted model still has a proposal "in flight", which the guard + /// would otherwise refuse. + Future _broadcastOriginalWithRetry( + Payjoin payjoin, { + int maxAttempts = 3, + Duration? delayBetweenAttempts, + }) async { + final delay = delayBetweenAttempts ?? fallbackRetryDelay; + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final result = await _broadcastOriginalTransaction(payjoin); + if (result != null) return result; + if (attempt < maxAttempts) { + await Future.delayed(delay); + } } + // Each failed attempt already logged SEVERE (with the underlying error) + // inside _broadcastOriginalTransaction — this is the summary line. + // logRef, never id: this accepts any Payjoin and a sender id is the full + // BIP21 URI. + log.warning( + 'Failed to broadcast the original transaction after $maxAttempts ' + 'attempts; leaving payjoin ${payjoin.logRef} unfinished so the next app ' + 'start retries', + ); + return null; } + /// Whether an incoming payjoin request should be declined for being below + /// the configured minimum-receive-amount threshold (anti-probing, + /// BIP78). `null` (no amount known yet) never counts as below minimum — + /// this only gates once the original transaction has actually been + /// retrieved and its amount computed (see PdkPayjoinDatasource's receiver + /// poll), never blocking on an incomplete read. + @visibleForTesting + static bool isBelowPayjoinMinimum({ + required int? amountSat, + required int minAmountSat, + }) => amountSat != null && amountSat < minAmountSat; + Future _processPayjoinProposal(PayjoinSenderModel payjoinModel) async { + // The proposal event carries the datasource's own in-memory copy, which + // may have resolved since (e.g. the fallback watcher aborted the session + // when the counterparty broadcast the original while this proposal was in + // flight). Re-fetch and bail on an already-terminal session — mirroring + // _processExpiredPayjoin — so we neither resurrect an aborted row (the + // insertOnConflictUpdate below replaces the whole row) nor sign/broadcast + // against a session that already resolved another way. + final persisted = await _localPayjoinDatasource.fetchSender( + payjoinModel.id, + ); + if (persisted != null && (persisted.isCompleted || persisted.isAborted)) { + return; + } + // Update the local database with the new payjoin proposal await _localPayjoinDatasource.update(payjoinModel); @@ -313,75 +667,367 @@ class PayjoinRepositoryImpl implements PayjoinRepository { ? Network.bitcoinTestnet : Network.bitcoinMainnet, ); - log.info( - 'Payjoin proposal broadcasted: ${payjoin.id} with txId: ${result.txId}', - ); + // logRef, never id/raw txid: a sender payjoin id is the full BIP21 URI + // and a raw txid identifies the payment on-chain — both off-limits in + // logs. + log.info('Payjoin proposal broadcasted for ${payjoin.logRef}'); + // Label the transaction now that a REAL payjoin actually broadcast + // (never a fallback — see _broadcastOriginalTransaction, which marks + // aborted sessions aborted, not completed) and the final txid is known. + // Deliberately re-derived from [finalizedPsbt] — the bytes actually + // broadcast — rather than trusting `result.txId`/the pre-existing + // `payjoin.txId`: those are computed from the proposal PSBT before this + // signPsbt call above, and finalizing a signature can change the txid + // for non-native-segwit inputs (never label a txid that wasn't derived + // from the actually-broadcast bytes). The receiver side has the + // watch-for-broadcast infra to notice its own completion, but labels + // the pre-finalization txid because it never sees the finalized bytes — + // an inherent limitation, not a missing feature. + final broadcastTxId = (await BitcoinTx.fromPsbt(finalizedPsbt)).txid; + await labelCompletedPayjoinSend(broadcastTxId); } catch (e) { log.severe( message: 'Error broadcasting payjoin proposal', error: e, trace: StackTrace.current, ); - // TODO: Handle this, maybe by sending the original transaction instead + // Signing or broadcasting the finalized payjoin transaction failed. By + // this point the sender's poll timer that would otherwise raise an + // expiry is already cancelled (it stopped the moment this proposal + // arrived), so nothing else will ever emit a terminal event for this + // session — fall back to broadcasting the original transaction + // ourselves, mirroring the sender-expiry fallback, so the payment + // still goes through and the send flow doesn't hang forever (#2246). + result = await _broadcastOriginalTransaction(payjoin) as PayjoinSender?; } if (result != null) { _payjoinStreamController.add(result); + } else { + // Both the payjoin and the original-transaction fallback failed: mark + // the session terminally failed (modelled as expired, which + // SendCubit._watchPayjoin already surfaces as a broadcast failure) + // instead of leaving the send flow hanging on "coordinating" with no + // event ever arriving again. Clear txId: the pre-existing value is a + // proposal-derived payjoin txid that never reached the chain, and a + // stale txid on a terminal session pollutes fetchByTxId / the details + // screen (same display-hygiene reason _broadcastOriginalTransaction + // clears it on the abort path). + // + // Re-fetch and bail on terminal first: the sign/broadcast attempt + // above took a real network round-trip, during which the fallback + // watcher could have marked this same session aborted (the original + // landing on-chain is exactly what makes the payjoin broadcast fail + // on already-spent inputs, and the original re-broadcast fail as + // already-known). Persisting from the stale `payjoinModel` here would + // overwrite that correct `aborted` outcome with `expired`. + final freshModel = await _localPayjoinDatasource.fetchSender( + payjoinModel.id, + ); + if (freshModel != null && + (freshModel.isCompleted || freshModel.isAborted)) { + return; + } + final failedModel = (freshModel ?? payjoinModel).copyWith( + isExpired: true, + txId: null, + ); + await _localPayjoinDatasource.update(failedModel); + _payjoinStreamController.add(failedModel.toEntity()); } } Future _processExpiredPayjoin(PayjoinModel payjoinModel) async { - // Update the local database with the expired payjoin - await _localPayjoinDatasource.update(payjoinModel); + // The expiry event carries the emitter's own in-memory copy of the + // session (the PDK poll's model from when polling started, or a resume's + // fetch) — not the persisted row, which may have resolved in the + // meantime through a path the emitter can't see (the fallback watcher + // marking it aborted/completed when a transaction landed on-chain). + // Re-fetch and bail on an already-terminal session: without this, an + // expiry firing after a fallback completion re-broadcast the original + // transaction and re-emitted terminal events for a resolved session + // (observed live: a sender's poll expired a minute after + // _onOriginalTransactionSeen had already completed the session). + final freshModel = payjoinModel is PayjoinReceiverModel + ? await _localPayjoinDatasource.fetchReceiver(payjoinModel.id) + : await _localPayjoinDatasource.fetchSender(payjoinModel.id); + if (freshModel == null || freshModel.isCompleted || freshModel.isAborted) { + return; + } - final payjoin = payjoinModel.toEntity(); - - _payjoinStreamController.add(payjoin); + // Continue with the fresh row (expired-marked, like every caller marks + // its own copy), not the event's copy: the branches below decide on + // proposalPsbt/originalTxBytes, and the last branch persists the model — + // doing either with the stale copy could clobber fields persisted since + // the emitter captured it (insertOnConflictUpdate replaces the row). + final expiredModel = freshModel.copyWith(isExpired: true); + final payjoin = expiredModel.toEntity(); // TODO: Unfreeze the utxo used in the payjoin - if (payjoin is PayjoinReceiver && payjoin.originalTxBytes != null) { - // If the payjoin is a receiver and it has the original transaction bytes - // at expiration, we broadcast the original transaction automatically. - await tryBroadcastOriginalTransaction(payjoin); + if (payjoin is PayjoinReceiver && + payjoin.originalTxBytes != null && + payjoin.proposalPsbt == null) { + // We received the sender's original transaction but never sent a + // proposal before expiring, so broadcast that original automatically — + // the receiver still gets paid, and (per BIP78) doing so imposes the + // mining-fee cost that makes UTXO probing non-free. + // Guard on proposalPsbt == null: once a proposal has gone out, the + // sender owns finalizing/broadcasting the payjoin transaction, which + // spends the same inputs. Broadcasting the original here would just + // race our own in-flight payjoin for no benefit. + // + // Emit only the terminal outcome — mirroring the sender-expiry + // fallback below — so a receive screen watching this session doesn't + // see an interim "expired" state that suggests the original still + // needs manual broadcasting when it's actually already in flight. + // + // Deliberately do NOT call _stopWatching before attempting the + // broadcast: the fallback watch armed in _processPayjoinRequest must + // survive a failed attempt here so it keeps watching for the original + // transaction to land through ANY path — a later resume's retry, or + // the sender broadcasting it independently in the meantime. + // _broadcastOriginalTransaction already stops it on success; leaving + // it running on failure is what fixes a session getting permanently + // stuck otherwise (observed live). + // + // Deliberately do NOT persist the raw expired model first: + // _broadcastOriginalTransaction persists isAborted itself on success. + // If the broadcast fails, leaving the row unfinished means the next + // app start's resumePayjoinsOnStartup sees isExpiryTimePassed still + // true and retries the fallback — persisting isExpired here would + // instead permanently exclude it from onlyUnfinished and drop the + // retry. + final result = await _broadcastOriginalTransaction(payjoin); + _payjoinStreamController.add(result ?? payjoin); + } else if (payjoin is PayjoinSender && payjoin.proposalPsbt == null) { + // The sender never received a proposal before expiry (the receiver + // didn't respond), so fall back to broadcasting the original + // transaction — the payment must still go through. Guard on + // proposalPsbt == null: once a proposal arrived, _processPayjoinProposal + // owns finalizing/broadcasting the payjoin transaction (same inputs). + // + // Emit only the terminal outcome: the aborted result on success (so + // the send flow resolves to success), or the raw expired entity if the + // fallback broadcast itself failed (so listeners see a terminal state + // and don't hang on the "coordinating" screen). We deliberately do NOT + // emit the interim expired entity before the fallback, which would race + // the aborted one on the stream. + // + // Same reasoning as the receiver branch above for not persisting the + // expired flag up front, and for NOT calling _stopWatching before + // attempting: the fallback watch armed at session creation must + // survive a failed attempt here. + final result = await _broadcastOriginalTransaction(payjoin); + _payjoinStreamController.add(result ?? payjoin); + } else { + // A receiver whose proposal was already sent (proposalPsbt != null) + // lands here. From here the sender owns finalizing/broadcasting the + // payjoin transaction, which can still land on-chain after this + // session's own expiry — deliberately do NOT stop the broadcast + // watcher armed for it (_watchForBroadcast): once the tx is seen, + // _onPayjoinTransactionSeen completes and labels the session even + // though it's presently marked expired. Stopping the watcher here + // would strand the session as permanently expired despite the payment + // actually completing. + // + // Beyond hardening: re-arm the broadcast watcher idempotently. For a + // live expiry this is a no-op (the containsKey guard — the watcher + // armed at proposal time is still registered); for a resume-time + // expiry (app was closed) it re-arms the watcher the session lost + // when the app closed, which is exactly the behavior the retention + // above intends. + if (expiredModel is PayjoinReceiverModel && expiredModel.txId != null) { + _watchForBroadcast( + payjoinId: expiredModel.id, + walletId: expiredModel.walletId, + txId: expiredModel.txId!, + ); + // Same reasoning as _resumeOne's equivalent receiver branch: the + // sender could independently fall back to the original transaction + // instead of finalizing the real payjoin, and this receiver would + // otherwise have no way to find out — it only watches the payjoin + // txid above. Without this, a receiver resumed here stays marked + // expired forever despite having actually been paid via the + // sender's fallback. + if (expiredModel.originalTxId != null) { + _watchForFallback( + payjoinId: expiredModel.id, + walletId: expiredModel.walletId, + originalTxId: expiredModel.originalTxId!, + ); + } + } + + // Nothing left for us to retry from this side, so persist the expired + // marker now (unlike the two fallback branches above). + await _localPayjoinDatasource.update(expiredModel); + _payjoinStreamController.add(payjoin); } } - Future _resumePayjoins() async { + @override + Future resumePayjoinsOnStartup() async { + // The whole body is inside this try/catch: this method is invoked as + // `unawaited(...)` from AppLocator.setup, so anything that throws here + // (e.g. the DB not yet ready, a fetch failing) would otherwise surface + // as an unhandled zone error during app startup instead of a logged, + // contained failure. Per-session failures inside the resume loop below + // are already isolated by their own try/catch; this outer one covers + // the sweep queries and loops themselves. + try { + await _resumePayjoinsOnStartupUnguarded(); + } catch (e, st) { + log.severe( + message: 'Failed to resume payjoin sessions on startup', + error: e, + trace: st, + ); + } + } + + Future _resumePayjoinsOnStartupUnguarded() async { + // Sweep receivers whose expiry-time fallback broadcast FAILED on a + // previous run and were persisted as expired (e.g. by pre-port code that + // persisted isExpired before broadcasting): the onlyUnfinished resume + // below can never see them again while they still hold the sender's + // broadcastable original transaction. Without this sweep the sender's + // payment would be stranded forever. One attempt per app start per + // session: success marks it aborted (ending the retries); a transient + // failure — or the tx already being on-chain, which the broadcast + // surfaces as an error — retries on the next start. Uses the internal + // broadcast (bypassing the manual-call guard): this is an automatic + // recovery retry, not a manual action. + final receivers = await _localPayjoinDatasource.fetchReceivers(); + for (final receiver in receivers) { + if (receiver.isExpired && + !receiver.isAborted && + !receiver.isCompleted && + receiver.originalTxBytes != null && + receiver.proposalPsbt == null) { + await _broadcastOriginalTransaction(receiver.toEntity()); + // Arm the fallback watch regardless of the attempt's outcome above: + // _broadcastOriginalTransaction only arms it on SUCCESS. If it + // failed because the tx is already on-chain (the other side beat + // us to broadcasting it) rather than a transient error, this row + // would otherwise never be marked aborted and this sweep would + // retry — and log SEVERE — on every subsequent app start + // indefinitely. Idempotent: a no-op if already armed by a + // successful attempt above. + if (receiver.originalTxId != null) { + _watchForFallback( + payjoinId: receiver.id, + walletId: receiver.walletId, + originalTxId: receiver.originalTxId!, + ); + } + } + } + + // Same sweep for senders: a sender's original transaction (originalPsbt) + // is always available from session creation, unlike a receiver's (which + // only arrives with the request), so the only guard needed here is + // proposalPsbt == null — mirroring the receiver branch above. + final senders = await _localPayjoinDatasource.fetchSenders(); + for (final sender in senders) { + if (sender.isExpired && + !sender.isAborted && + !sender.isCompleted && + sender.proposalPsbt == null) { + await _broadcastOriginalTransaction(sender.toEntity()); + // Same reasoning as the receiver sweep above: converge a failed + // attempt (e.g. already on-chain via the other side) via the + // fallback watch instead of retrying forever. A sender's + // originalTxId is always present (set at session creation). + _watchForFallback( + payjoinId: sender.id, + walletId: sender.walletId, + originalTxId: sender.originalTxId, + ); + } + } + final models = await _localPayjoinDatasource.fetchAll(onlyUnfinished: true); for (final model in models) { - if (model.isExpiryTimePassed) { - // 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 - // needs to listen for a payjoin request from the sender. - _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 - // it should be processed. - await _processPayjoinRequest(model); - } else { - // Todo: listen for the broadcast of the transaction - } - } else if (model is PayjoinSenderModel) { - 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. - _pdkPayjoinDatasource.startListeningForProposal(model); - } else { - // If the proposal psbt is present, it means a payjoin proposal was - // already received and it should be processed. - await _processPayjoinProposal(model); + // Each session is independent: a bug or a transient failure resuming + // one (e.g. a missing wallet, a bad persisted event log) must not + // abort the loop and silently leave every other unfinished session + // un-resumed. + try { + await _resumeOne(model); + } catch (e, st) { + // logRef, never id: this SEVERE reaches Sentry, and a sender id is + // the full BIP21 URI. + log.severe( + message: + 'Failed to resume payjoin session ${model.toEntity().logRef}', + error: e, + trace: st, + ); + } + } + } + + Future _resumeOne(PayjoinModel model) async { + if (model.isExpiryTimePassed) { + // 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 + // needs to listen for a payjoin request from the sender. + _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 + // it should be processed. + await _processPayjoinRequest(model); + } else if (model.txId != null) { + // A proposal was already sent before the app closed. Resume watching + // for the payjoin transaction to appear on-chain so the session can + // be completed and its transaction labelled. + _watchForBroadcast( + payjoinId: model.id, + walletId: model.walletId, + txId: model.txId!, + ); + // The sender still owns finalizing/broadcasting the real proposal + // from here, but could instead fall back to the original + // transaction on ITS side without this receiver ever being told — + // resume the same safety net armed when the request first arrived + // (see _watchForFallback's doc comment). + if (model.originalTxId != null) { + _watchForFallback( + payjoinId: model.id, + walletId: model.walletId, + originalTxId: model.originalTxId!, + ); } } + } else if (model is PayjoinSenderModel) { + // Resume the fallback safety net regardless of which branch below is + // taken: originalTxId is always known for a sender (set at creation), + // and the receiver could have broadcast it independently at any point + // while the app was closed (see _watchForFallback's doc comment). + _watchForFallback( + payjoinId: model.id, + walletId: model.walletId, + originalTxId: model.originalTxId, + ); + 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. + _pdkPayjoinDatasource.startListeningForProposal(model); + } else { + // If the proposal psbt is present, it means a payjoin proposal was + // already received and it should be processed. + await _processPayjoinProposal(model); + } } } @@ -422,6 +1068,21 @@ class PayjoinRepositoryImpl implements PayjoinRepository { payjoin.id, ); if (freshModel == null) throw Exception('Payjoin receiver not found'); + // Re-check terminal state under the lock: a manual "receive payment + // normally" tap (allowed by canManuallyBroadcastOriginal while + // proposalPsbt == null) or the fallback watcher could have resolved + // this session while we were awaiting getUtxosFrozenByOngoingPayjoins + // above. Without this, an already-aborted/completed/expired row would + // be resurrected to `proposed` by the update below. + if (freshModel.isCompleted || + freshModel.isAborted || + freshModel.isExpired) { + log.warning( + 'Skipping payjoin proposal for ${freshModel.toEntity().logRef}: ' + 'session already resolved (${freshModel.status})', + ); + return null; + } final isMineSync = await _bdkWallet.createIsMineChecker(wallet: wallet); final signPsbtSync = await _bdkWallet.createPsbtSigner(wallet: wallet); @@ -468,11 +1129,442 @@ class PayjoinRepositoryImpl implements PayjoinRepository { if (model == null) { throw Exception('Payjoin sender not found'); } + if (model.isAborted) { + // The fallback watcher aborted this session (the ORIGINAL transaction + // was seen on-chain) while we were signing/broadcasting the proposal. + // Our payjoin transaction nonetheless made it onto the network and, + // spending the same inputs, will confirm instead of the original — so + // completing (real payjoin) is the correct on-chain outcome and wins + // over the aborted marker (model derivation orders isCompleted first). + // Logged so this genuinely-rare race is visible rather than an + // implicit derivation-order side effect. + log.warning( + 'Payjoin ${model.toEntity().logRef} completed via a real payjoin ' + 'after having been marked aborted (both transactions raced onto the ' + 'network; the payjoin tx wins)', + ); + } final completedModel = model.copyWith(isCompleted: true); await _localPayjoinDatasource.update(completedModel); + // Best-effort, non-blocking: see _broadcastOriginalTransaction's call + // for why this can't wait on some unrelated sync to run. + _syncWalletAfterBroadcast(completedModel.walletId); + + // The session is resolved by the payjoin transaction we just broadcast: + // stop the original-tx fallback watch armed at session creation (it + // would otherwise keep looking up a transaction that can never land on + // every sync for the rest of the app's lifetime) and watch the payjoin + // txid instead until it is visible in the local wallet — the single + // unawaited sync above can be throttled or race the broadcast (see + // _broadcastOriginalTransaction). Self-tearing-down like the fallback + // watch: _onPayjoinTransactionSeen stops all watchers first, and its + // fetchReceiver returns null for this SENDER session, making it a pure + // teardown. + _stopWatching(payjoinId); + if (completedModel.txId != null) { + _watchForBroadcast( + payjoinId: payjoinId, + walletId: completedModel.walletId, + txId: completedModel.txId!, + ); + } + return completedModel.toEntity() as PayjoinSender; } + + /// Delay before the first active broadcast poll of [_watchForBroadcast]. + /// The sender typically finalizes and broadcasts within seconds of + /// receiving the proposal, so the first forced lookup comes quickly. + @visibleForTesting + static const broadcastPollInitialDelay = Duration(seconds: 5); + + /// Cap for the exponential backoff between active broadcast polls. + @visibleForTesting + static const broadcastPollMaxDelay = Duration(minutes: 5); + + /// Number of active broadcast polls before giving up on forcing syncs + /// ourselves (≈35 minutes with the initial delay doubling up to the cap). + /// The passive sync-driven watcher stays armed afterwards, so a very late + /// broadcast is still caught by the next organic wallet sync — this bound + /// only stops a stranded session from forcing network syncs forever. + @visibleForTesting + static const broadcastPollMaxAttempts = 12; + + /// Watches for the receiver's payjoin transaction [txId] to appear in + /// [walletId], then marks the session completed, labels the transaction, + /// and stops watching. Two complementary triggers: + /// + /// - Passive: a cheap local lookup whenever a sync of this wallet finishes + /// (the stream re-emits on every sync, so the first successful hit + /// cancels the watcher to keep the completion side effect one-shot). + /// - Active: a bounded backoff of forced `sync: true` lookups. Without it, + /// completion depended entirely on some unrelated sync happening to run + /// while the session was live — observed live as a receiver stuck on + /// "payjoin in progress" for ~9 minutes after the sender had already + /// broadcast the payjoin transaction, because nothing else synced the + /// wallet in the meantime. + /// + /// Idempotent: a session already being watched (live path then resume, or + /// duplicate resume) is not re-subscribed. + void _watchForBroadcast({ + required String payjoinId, + required String walletId, + required String txId, + }) => _watchForTransaction( + payjoinId: payjoinId, + walletId: walletId, + txId: txId, + watchers: _broadcastWatchers, + pollTimers: _broadcastPollTimers, + onSeen: _onPayjoinTransactionSeen, + kind: 'broadcast', + ); + + /// Watches for the ORIGINAL transaction [originalTxId] to appear in + /// [walletId] — the safety net for the plain-broadcast fallback landing + /// through a path this device didn't itself observe succeed. + /// + /// Both a sender and a receiver hold their own copy of the original + /// transaction and can each independently decide to broadcast it (a + /// receiver declining below the anti-probing minimum, either side's + /// session expiring with no proposal exchanged, or a sender's own + /// negotiation failing). Whichever side attempts the broadcast persists + /// the terminal state itself on success, but there was previously no way + /// for the OTHER side to find out — it just kept waiting on its own + /// session with no signal that the payment had already landed via the + /// other side's fallback. Observed live: a receiver declining + /// below-minimum broadcasts the original immediately, while the sender's + /// own session sat on "requested" for up to a minute until ITS expiry + /// timer independently fired — and if that second, redundant broadcast + /// attempt then errored (the tx was already known to the network), the + /// sender's session never completed at all. + /// + /// Armed as soon as `originalTxId` is known — session creation for a + /// sender, request-received for a receiver — and left running alongside + /// any later [_watchForBroadcast] for the same session: whichever of the + /// real payjoin txid or this original txid lands on-chain first resolves + /// the session, and [_stopWatching] tears down both together. Mirrors + /// [_watchForBroadcast]'s passive+active polling exactly. + /// + /// Idempotent, same as [_watchForBroadcast]. + void _watchForFallback({ + required String payjoinId, + required String walletId, + required String originalTxId, + }) => _watchForTransaction( + payjoinId: payjoinId, + walletId: walletId, + txId: originalTxId, + watchers: _fallbackWatchers, + pollTimers: _fallbackPollTimers, + onSeen: _onOriginalTransactionSeen, + kind: 'fallback', + ); + + /// Shared engine behind [_watchForBroadcast] and [_watchForFallback]. See + /// those wrappers for the per-kind rationale; the two registries stay + /// separate because both watches can be live for one session at once and + /// [_stopWatching] tears them down together. + void _watchForTransaction({ + required String payjoinId, + required String walletId, + required String txId, + required Map> watchers, + required Map pollTimers, + required Future Function(String payjoinId) onSeen, + required String kind, + }) { + if (watchers.containsKey(payjoinId)) return; + + // Best-effort: failing to arm the watch (e.g. a wallet lookup throwing + // synchronously) must never take down the proposal/broadcast/resume + // handling it was called from — a successful broadcast misreported as + // failed would trigger a redundant fallback. + try { + final subscription = _walletRepository().walletSyncFinishedStream + .where((wallet) => wallet.id == walletId) + .asyncMap((_) async { + try { + return await _walletTransactionRepository().getWalletTransaction( + txId, + walletId: walletId, + ); + } catch (e) { + log.warning('Payjoin $kind watch lookup failed: $e'); + return null; + } + }) + .where((tx) => tx != null) + .listen((_) { + // onSeen (fetch + update + emit) can throw; the future is not + // awaited by the stream, so guard it explicitly or it becomes an + // unhandled zone error. + unawaited( + onSeen(payjoinId).catchError((Object e) { + log.warning('Payjoin $kind completion handler failed: $e'); + }), + ); + }); + + watchers[payjoinId] = subscription; + + _scheduleTransactionPoll( + payjoinId: payjoinId, + walletId: walletId, + txId: txId, + watchers: watchers, + pollTimers: pollTimers, + onSeen: onSeen, + kind: kind, + attempt: 0, + ); + } catch (e) { + // logRefForId: payjoinId is the raw session id, which for a sender IS + // the full BIP21 URI (address+amount) — never log it raw. + log.warning( + 'Failed to arm the $kind watch for ' + '${Payjoin.logRefForId(payjoinId)}: $e', + ); + } + } + + /// Arms the next active poll for [_watchForTransaction]: after an + /// exponentially backed-off delay, forces a sync'd wallet-transaction + /// lookup and either resolves the session or reschedules itself. The + /// `watchers` map is the single source of truth for "still watched": once + /// [_stopWatching] removed the session, a pending poll callback becomes a + /// no-op. + void _scheduleTransactionPoll({ + required String payjoinId, + required String walletId, + required String txId, + required Map> watchers, + required Map pollTimers, + required Future Function(String payjoinId) onSeen, + required String kind, + required int attempt, + }) { + if (attempt >= broadcastPollMaxAttempts) { + // The passive sync-driven watcher stays armed; only stop forcing syncs. + // Drop the fired timer from the map so it doesn't falsely advertise an + // active poll (its cancel() is already a no-op). + pollTimers.remove(payjoinId); + return; + } + + var delay = broadcastPollInitialDelay * (1 << attempt.clamp(0, 30)); + if (delay > broadcastPollMaxDelay) delay = broadcastPollMaxDelay; + + pollTimers[payjoinId] = Timer(delay, () async { + if (!watchers.containsKey(payjoinId)) return; + + WalletTransaction? tx; + try { + tx = await _walletTransactionRepository().getWalletTransaction( + txId, + walletId: walletId, + sync: true, + ); + } catch (e) { + log.warning('Payjoin $kind poll failed: $e'); + } + + // Re-check: the session may have resolved through the passive watcher + // (or been torn down) while the sync'd lookup was in flight. + if (!watchers.containsKey(payjoinId)) return; + + if (tx != null) { + // Guarded: this runs inside a Timer callback, so a throw would be an + // unhandled zone error (same reasoning as the passive watcher). + try { + await onSeen(payjoinId); + } catch (e) { + log.warning('Payjoin $kind completion handler failed: $e'); + } + } else { + _scheduleTransactionPoll( + payjoinId: payjoinId, + walletId: walletId, + txId: txId, + watchers: watchers, + pollTimers: pollTimers, + onSeen: onSeen, + kind: kind, + attempt: attempt + 1, + ); + } + }); + } + + Future _onPayjoinTransactionSeen(String payjoinId) async { + // Stop first: the watch stream re-emits on every sync, and completion is + // a one-shot side effect. + _stopWatching(payjoinId); + + final model = await _localPayjoinDatasource.fetchReceiver(payjoinId); + if (model == null || model.isCompleted || model.isAborted) return; + + final completedModel = model.copyWith(isCompleted: true); + await _localPayjoinDatasource.update(completedModel); + if (completedModel.txId != null) { + await _labelPayjoinTransaction( + txId: completedModel.txId!, + walletId: completedModel.walletId, + ); + } + _payjoinStreamController.add(completedModel.toEntity()); + log.info('Payjoin receiver completed on broadcast: $payjoinId'); + } + + /// The original transaction landed on-chain — regardless of which side + /// actually broadcast it (this repository's own attempt, possibly already + /// failed, or the counterparty's independent fallback) — so this session + /// is resolved via the plain-broadcast fallback (status `aborted`), never + /// a real payjoin. `txId` is cleared for the same reason + /// [_broadcastOriginalTransaction] clears it. Never labels the transaction + /// "payjoin" — this is by definition not a real one. + Future _onOriginalTransactionSeen(String payjoinId) async { + // Stop first: the watch stream re-emits on every sync, and completion is + // a one-shot side effect. Whichever of the real payjoin txid or this + // original txid lands first resolves the session, so both watchers are + // torn down together. + _stopWatching(payjoinId); + + final receiverModel = await _localPayjoinDatasource.fetchReceiver( + payjoinId, + ); + final PayjoinModel? model = + receiverModel ?? await _localPayjoinDatasource.fetchSender(payjoinId); + if (model == null || model.isCompleted || model.isAborted) return; + + final PayjoinModel abortedModel = model is PayjoinReceiverModel + ? model.copyWith(isAborted: true, txId: null) + : (model as PayjoinSenderModel).copyWith(isAborted: true, txId: null); + await _localPayjoinDatasource.update(abortedModel); + _syncWalletAfterBroadcast(abortedModel.walletId); + final abortedEntity = abortedModel.toEntity(); + _payjoinStreamController.add(abortedEntity); + log.info( + 'Payjoin ${abortedEntity.logRef} resolved via the original ' + 'transaction observed on-chain (fallback, not necessarily broadcast ' + 'by this device)', + ); + } + + /// Stops every watcher of a session — the real-payjoin broadcast watch + /// ([_watchForBroadcast]), the original-transaction fallback watch + /// ([_watchForFallback]) AND the PDK directory poll — since the session + /// resolving through any one of them means there is nothing left to watch + /// for on the others. Stopping the directory poll matters as much as the + /// two watchers: a session resolved via the fallback otherwise keeps its + /// request/proposal poll firing until expiry, which then raises a stale + /// expired event for an already-completed session (see + /// [_processExpiredPayjoin]'s guard). Synchronous on purpose: + /// `StreamSubscription.cancel()` already guarantees no further events are + /// delivered from the moment it is CALLED, so nothing here needs to block + /// on its returned future (which only signals resource cleanup) — and + /// awaiting it would make completion latency depend on the upstream + /// stream's teardown. + void _stopWatching(String payjoinId) { + _broadcastPollTimers.remove(payjoinId)?.cancel(); + final broadcastSubscription = _broadcastWatchers.remove(payjoinId); + if (broadcastSubscription != null) { + unawaited(broadcastSubscription.cancel()); + } + _fallbackPollTimers.remove(payjoinId)?.cancel(); + final fallbackSubscription = _fallbackWatchers.remove(payjoinId); + if (fallbackSubscription != null) { + unawaited(fallbackSubscription.cancel()); + } + _pdkPayjoinDatasource.stopPolling(payjoinId); + } + + /// Fire-and-forget wallet sync after WE broadcast a transaction (either a + /// real payjoin proposal or a plain fallback) — the only two places this + /// repository itself puts a new transaction on the network. Not awaited by + /// callers: it must never delay resolving the payjoin session (this can + /// take a real network round-trip), and a transient failure here (no + /// network at that exact moment) shouldn't be treated as the broadcast — + /// which already succeeded — having failed. The next organic sync (or the + /// receiver's own _watchForBroadcast poll) still catches up eventually. + /// + /// The call is wrapped in `Future(() => ...)` rather than invoked directly: + /// this repository's callers (_broadcastOriginalTransaction, _broadcastPsbt) + /// already wrap their whole body in a try/catch for the broadcast itself, + /// so a SYNCHRONOUS throw from `_walletRepository()` or `getWallet(...)` (as + /// opposed to the future it returns rejecting) would otherwise be caught by + /// that outer try/catch and misreported as the broadcast having failed, + /// even though it already succeeded. + void _syncWalletAfterBroadcast(String walletId) { + unawaited( + Future( + () => _walletRepository().getWallet(walletId, sync: true), + ).catchError((Object e) { + log.warning('Failed to sync wallet after payjoin broadcast: $e'); + return null; + }), + ); + } + + /// Tags a completed payjoin transaction with the payjoin system label so it + /// is recognisable as a payjoin in the transaction list. Best-effort: a + /// labelling failure must never fail the (already broadcast) payjoin, so it + /// is logged and swallowed. Idempotent — the labels store dedupes on + /// (label, reference). + Future _labelPayjoinTransaction({ + required String txId, + required String walletId, + }) async { + try { + final result = await _labelsFacade().store( + NewLabel.tx( + transactionId: txId, + label: LabelSystem.payjoin.label, + origin: walletId, + ), + ); + result.fold( + (_) {}, + (failure) => log.warning( + 'Failed to label payjoin transaction', + error: failure.logMessage, + ), + ); + } catch (e) { + log.warning('Failed to label payjoin transaction', error: e); + } + } + + /// Best-effort and idempotent, matching the labelling elsewhere in the + /// codebase that touches this same facade from core (see + /// LabelExchangeOrdersUsecase, AutoSwapExecutionUsecase): a labelling + /// failure is logged and swallowed so it never fails the already-broadcast + /// payjoin, and the labels store dedupes on (label, reference) so a + /// repeated call for the same txid is harmless. + /// + /// Not private (and marked [visibleForTesting]) so it can be exercised + /// directly: driving it through the full reactive sender pipeline needs a + /// real, valid PSBT for BitcoinTx.fromPsbt to parse (FFI-backed — the + /// existing payjoin datasource tests document the same offline-fixture + /// constraint), which isn't practical to construct in a unit test. + @visibleForTesting + Future labelCompletedPayjoinSend(String txId) async { + try { + final result = await _labelsFacade().store( + NewLabel.tx(transactionId: txId, label: LabelSystem.payjoin.label), + ); + result.fold( + (_) {}, + (failure) => log.warning( + 'Failed to label completed payjoin send', + error: failure.logMessage, + ), + ); + } catch (e) { + log.warning('Failed to label completed payjoin send', error: e); + } + } } class NoInputsToPayjoinException extends BullException { diff --git a/lib/core/payjoin/domain/repositories/payjoin_repository.dart b/lib/core/payjoin/domain/repositories/payjoin_repository.dart index 2423a53325..a90487c4ad 100644 --- a/lib/core/payjoin/domain/repositories/payjoin_repository.dart +++ b/lib/core/payjoin/domain/repositories/payjoin_repository.dart @@ -31,4 +31,12 @@ abstract class PayjoinRepository { required int expireAfterSec, }); Future tryBroadcastOriginalTransaction(Payjoin payjoin); + + /// Resumes polling/watching for every unfinished payjoin session left over + /// from a previous app run. A composition-root lifecycle hook: the + /// repository is constructed as an eager singleton before every dependency + /// it needs (wallet repositories, the labels facade) is registered, so this + /// must be called explicitly once every core dependency it needs is + /// registered, rather than fired from the constructor — see AppLocator.setup. + Future resumePayjoinsOnStartup(); } diff --git a/lib/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart b/lib/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart index c7dbe9365b..5de0c57e3a 100644 --- a/lib/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart +++ b/lib/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart @@ -2,7 +2,6 @@ import 'package:bb_mobile/core/errors/bull_exception.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/settings/data/settings_repository.dart'; -import 'package:bb_mobile/core/utils/constants.dart'; class ReceiveWithPayjoinUsecase { final PayjoinRepository _payjoinRepository; @@ -13,17 +12,13 @@ class ReceiveWithPayjoinUsecase { required this._settingsRepository, }); - /// Returns null if payjoin is disabled in settings — the caller should - /// treat that the same as "no payjoin for this address", not an error. - Future execute({ + Future execute({ required String walletId, required String address, int? expireAfterSec, }) async { try { final settings = await _settingsRepository.fetch(); - if (!settings.isPayjoinEnabled) return null; - final environment = settings.environment; final payjoinReceiver = await _payjoinRepository.createPayjoinReceiver( @@ -31,8 +26,9 @@ class ReceiveWithPayjoinUsecase { address: address, isTestnet: environment.isTestnet, maxFeeRateSatPerVb: BigInt.from(10000), - expireAfterSec: - expireAfterSec ?? PayjoinConstants.defaultExpireAfterSec, + // The user-configured session lifetime (see the payjoin settings + // screen) unless the caller explicitly overrides it (e.g. tests). + expireAfterSec: expireAfterSec ?? settings.payjoinExpireAfterSec, ); return payjoinReceiver; diff --git a/lib/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart b/lib/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart index 12e1e0327c..1a2970f9f6 100644 --- a/lib/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart +++ b/lib/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart @@ -1,16 +1,18 @@ import 'package:bb_mobile/core/errors/bull_exception.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/utils/constants.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; import 'package:bb_mobile/core/wallet/data/repositories/bitcoin_wallet_repository.dart'; class SendWithPayjoinUsecase { final PayjoinRepository _payjoinRepository; final BitcoinWalletRepository _bitcoinWalletRepository; + final SettingsRepository _settingsRepository; const SendWithPayjoinUsecase({ required this._payjoinRepository, required this._bitcoinWalletRepository, + required this._settingsRepository, }); Future execute({ @@ -28,6 +30,12 @@ class SendWithPayjoinUsecase { walletId: walletId, ); + // The user-configured session lifetime (payjoin settings screen) + // unless the caller explicitly overrides it (e.g. tests) — resolved + // here, not by the caller, to mirror ReceiveWithPayjoinUsecase so the + // two sides can't silently drift apart. + final settings = await _settingsRepository.fetch(); + final pjSender = await _payjoinRepository.createPayjoinSender( walletId: walletId, isTestnet: isTestnet, @@ -35,8 +43,7 @@ class SendWithPayjoinUsecase { originalPsbt: signedOriginalPsbt, amountSat: amountSat, networkFeesSatPerVb: networkFeesSatPerVb, - expireAfterSec: - expireAfterSec ?? PayjoinConstants.defaultExpireAfterSec, + expireAfterSec: expireAfterSec ?? settings.payjoinExpireAfterSec, ); return pjSender; diff --git a/lib/core/payjoin/domain/usecases/watch_payjoin_usecase.dart b/lib/core/payjoin/domain/usecases/watch_payjoin_usecase.dart index 01c3f76ef8..118e7b9fd9 100644 --- a/lib/core/payjoin/domain/usecases/watch_payjoin_usecase.dart +++ b/lib/core/payjoin/domain/usecases/watch_payjoin_usecase.dart @@ -7,12 +7,15 @@ class WatchPayjoinUsecase { const WatchPayjoinUsecase({required this._payjoinRepository}); - Stream execute({List? ids}) { + /// Emits every payjoin update (both [PayjoinReceiver] and [PayjoinSender]), + /// optionally scoped to [ids]. Consumers that only care about one side + /// filter the concrete type themselves — the sender send-flow needs sender + /// completion events, which a receiver-only filter here would swallow. + Stream execute({List? ids}) { try { - return _payjoinRepository.payjoinStream - .where((payjoin) => payjoin is PayjoinReceiver) - .cast() - .where((payjoin) => ids == null || ids.contains(payjoin.id)); + return _payjoinRepository.payjoinStream.where( + (payjoin) => ids == null || ids.contains(payjoin.id), + ); } catch (e) { throw WatchPayjoinException(e.toString()); } diff --git a/lib/core/payjoin/payjoin_locator.dart b/lib/core/payjoin/payjoin_locator.dart index f9925e61d4..153c55dd67 100644 --- a/lib/core/payjoin/payjoin_locator.dart +++ b/lib/core/payjoin/payjoin_locator.dart @@ -17,6 +17,9 @@ import 'package:bb_mobile/core/storage/sqlite_database.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/repositories/bitcoin_wallet_repository.dart'; +import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart'; +import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; @@ -39,7 +42,19 @@ class PayjoinLocator { BaseOptions( connectTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), + // receiveTimeout MUST exceed the payjoin directory's long-poll + // hold: payjo.in (payjoin-mailroom) keeps an empty-mailbox poll + // open for ~30s before answering 202 Accepted. A timeout at or + // below that hold races it and loses every time — each empty + // poll aborts just before the 202, is misread as a relay + // failure, and cascades through all three relays, so a payjoin + // never completes and every send falls back to the original + // transaction (a real, previously-shipped bug). 35s = the ~30s + // hold + a minimal margin for relay forwarding and OHTTP/TLS + // overhead. The session's own expiry (default 24h — see + // PayjoinConstants) sits far above this, so the poll budget is + // bounded purely by this long-poll-hold floor plus margin. + receiveTimeout: const Duration(seconds: 35), ), ), ), @@ -47,8 +62,10 @@ class PayjoinLocator { } static void registerRepositories(GetIt locator) { - // Not a lazy singleton, because it should resume payjoins from the - // moment the app starts. + // Eager (not lazy) singleton: it subscribes to the datasource's event + // streams at construction. Resuming unfinished sessions is deferred to + // an explicit resumePayjoinsOnStartup() call from AppLocator.setup (see + // that method), once every dependency it needs is registered. locator.registerSingleton( PayjoinRepositoryImpl( localPayjoinDatasource: locator(), @@ -58,6 +75,19 @@ class PayjoinLocator { seedDatasource: locator(), blockchainDatasource: locator(), serversPort: locator(), + // Lazy: WalletLocator registers these AFTER + // PayjoinLocator.registerRepositories (see core_locator.dart), and + // this repository is an eager registerSingleton. They are only + // called from broadcast/completion watchers, well after startup. + walletRepository: () => locator(), + walletTransactionRepository: () => + locator(), + settingsRepository: locator(), + // Lazy: LabelsLocator.registerFacade runs after + // PayjoinLocator.registerRepositories (see core_locator.dart), and + // this repository is an eager registerSingleton — see + // PayjoinRepositoryImpl's _labelsFacade doc comment. + labelsFacade: () => locator(), ), ); } @@ -85,6 +115,7 @@ class PayjoinLocator { () => SendWithPayjoinUsecase( payjoinRepository: locator(), bitcoinWalletRepository: locator(), + settingsRepository: locator(), ), ); diff --git a/lib/core/storage/migrations/schema_13_to_14.dart b/lib/core/storage/migrations/schema_13_to_14.dart index cfc0a68913..87ef04970d 100644 --- a/lib/core/storage/migrations/schema_13_to_14.dart +++ b/lib/core/storage/migrations/schema_13_to_14.dart @@ -1,4 +1,5 @@ import 'package:bb_mobile/core/storage/sqlite_database.steps.dart'; +import 'package:bb_mobile/core/utils/logger.dart'; import 'package:drift/drift.dart'; /// Migration from version 13 to 14 @@ -25,6 +26,10 @@ import 'package:drift/drift.dart'; /// expiry with an original available) — see PayjoinStatus.aborted. /// Previously this outcome was folded into 'is_completed', which made /// every plain fallback broadcast display as a completed payjoin. +/// +/// Adds the dismissed_announcements table: +/// - Records which home announcements the user has dismissed (announcement id +/// + dismissal timestamp). New table, created empty. class Schema13To14 { static Future migrate(Migrator m, Schema14 schema14) async { try { @@ -65,5 +70,21 @@ class Schema13To14 { } catch (e) { if (!e.toString().contains('duplicate column')) rethrow; } + + // New dismissed_announcements table: one row per home announcement the + // user has dismissed (announcement id + dismissal timestamp). A brand-new + // table, so existing installs simply start with zero dismissals. + try { + await m.createTable(schema14.dismissedAnnouncements); + } catch (e) { + // Idempotency guard: only swallow "table already exists" (a re-run over a + // partially-applied migration) — log it so a driver wording change + // surfaces instead of silently becoming a hard failure. + if (!e.toString().contains('already exists')) rethrow; + log.warning( + 'Schema13To14: dismissed_announcements already exists — skipping create', + error: e, + ); + } } } diff --git a/lib/core/storage/schemas/bull_database/drift_schema_v14.json b/lib/core/storage/schemas/bull_database/drift_schema_v14.json index 697ed936d5..ab43e2040e 100644 --- a/lib/core/storage/schemas/bull_database/drift_schema_v14.json +++ b/lib/core/storage/schemas/bull_database/drift_schema_v14.json @@ -2038,6 +2038,43 @@ "vout" ] } + }, + { + "id": 16, + "references": [], + "type": "table", + "data": { + "name": "dismissed_announcements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "announcement_id", + "getter_name": "announcementId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "dismissed_at", + "getter_name": "dismissedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "announcement_id" + ] + } } ], "fixed_sql": [ @@ -2184,6 +2221,15 @@ "sql": "CREATE TABLE IF NOT EXISTS \"frozen_utxos\" (\"wallet_id\" TEXT NOT NULL, \"tx_id\" TEXT NOT NULL, \"vout\" INTEGER NOT NULL, PRIMARY KEY (\"wallet_id\", \"tx_id\", \"vout\"));" } ] + }, + { + "name": "dismissed_announcements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"dismissed_announcements\" (\"announcement_id\" TEXT NOT NULL, \"dismissed_at\" TEXT NOT NULL, PRIMARY KEY (\"announcement_id\"));" + } + ] } ] } \ No newline at end of file diff --git a/lib/core/storage/sqlite_database.dart b/lib/core/storage/sqlite_database.dart index 6922883d51..ca253723c8 100644 --- a/lib/core/storage/sqlite_database.dart +++ b/lib/core/storage/sqlite_database.dart @@ -7,6 +7,7 @@ import 'package:bb_mobile/core/utils/logger.dart'; import 'package:bb_mobile/core/utils/report.dart'; import 'package:bb_mobile/core/storage/tables/auto_swap.dart'; import 'package:bb_mobile/core/storage/tables/bip85_derivations_table.dart'; +import 'package:bb_mobile/core/storage/tables/dismissed_announcements_table.dart'; import 'package:bb_mobile/core/storage/tables/electrum_servers_table.dart'; import 'package:bb_mobile/core/storage/tables/electrum_settings_table.dart'; import 'package:bb_mobile/core/storage/tables/frozen_utxos_table.dart'; @@ -49,6 +50,7 @@ part 'sqlite_database.g.dart'; Recoverbull, Prices, FrozenUtxos, + DismissedAnnouncements, ], ) class SqliteDatabase extends _$SqliteDatabase { diff --git a/lib/core/storage/sqlite_database.steps.dart b/lib/core/storage/sqlite_database.steps.dart index 57d3eb7d2c..edf9ad4b07 100644 --- a/lib/core/storage/sqlite_database.steps.dart +++ b/lib/core/storage/sqlite_database.steps.dart @@ -6270,6 +6270,7 @@ final class Schema14 extends i0.VersionedSchema { recoverbull, prices, frozenUtxos, + dismissedAnnouncements, ]; late final Shape0 transactions = Shape0( source: i0.VersionedTable( @@ -6610,6 +6611,17 @@ final class Schema14 extends i0.VersionedSchema { ), alias: null, ); + late final Shape43 dismissedAnnouncements = Shape43( + source: i0.VersionedTable( + entityName: 'dismissed_announcements', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(announcement_id)'], + columns: [_column_246, _column_247], + attachedDatabase: database, + ), + alias: null, + ); } class Shape40 extends i0.VersionedTable { @@ -6761,6 +6773,30 @@ class Shape42 extends i0.VersionedTable { columnsByName['is_aborted']! as i1.GeneratedColumn; } +class Shape43 extends i0.VersionedTable { + Shape43({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get announcementId => + columnsByName['announcement_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get dismissedAt => + columnsByName['dismissed_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_246(String aliasedName) => + i1.GeneratedColumn( + 'announcement_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_247(String aliasedName) => + i1.GeneratedColumn( + 'dismissed_at', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, diff --git a/lib/core/storage/tables/dismissed_announcements_table.dart b/lib/core/storage/tables/dismissed_announcements_table.dart new file mode 100644 index 0000000000..9ceab3b393 --- /dev/null +++ b/lib/core/storage/tables/dismissed_announcements_table.dart @@ -0,0 +1,24 @@ +import 'package:drift/drift.dart'; + +/// Persisted record that the user has dismissed a home announcement. +/// +/// One row per dismissed announcement, keyed by the announcement's stable +/// string id (the `AnnouncementId` enum name). A row's mere existence means +/// "dismissed"; [dismissedAt] lets a *periodic* announcement re-arm once +/// enough time has elapsed (snooze), while a *permanent* announcement simply +/// stays suppressed for as long as the row exists. +/// +/// Announcements themselves are defined in code (compile-time), not stored — +/// only the per-user dismissal fact is persisted here. +@DataClassName('DismissedAnnouncementRow') +class DismissedAnnouncements extends Table { + /// The `AnnouncementId` enum name (stable across releases). + TextColumn get announcementId => text()(); + + /// When the user dismissed it (UTC). Used to re-arm snooze-policy + /// announcements; ignored for permanent-policy ones. + DateTimeColumn get dismissedAt => dateTime()(); + + @override + Set get primaryKey => {announcementId}; +} diff --git a/lib/core/themes/colors.dart b/lib/core/themes/colors.dart index 7c72601874..752942b12d 100644 --- a/lib/core/themes/colors.dart +++ b/lib/core/themes/colors.dart @@ -47,6 +47,7 @@ class AppColors { final Color onError; final Color errorContainer; final Color success; + final Color onSuccess; final Color warning; final Color warningContainer; final Color info; @@ -96,6 +97,7 @@ class AppColors { required this.onError, required this.errorContainer, required this.success, + required this.onSuccess, required this.warning, required this.warningContainer, required this.info, @@ -140,6 +142,7 @@ class AppColors { onError: Color(0xFFFFFFFF), errorContainer: Color(0xFFFFEBEE), success: Color(0xFF34C759), + onSuccess: Color(0xFFFFFFFF), warning: Color(0xFFFB9300), warningContainer: Color(0xFFFFF4E6), info: Color(0xFF0063F7), @@ -184,6 +187,7 @@ class AppColors { onError: Color(0xFFFFFFFF), errorContainer: Color(0xFF3D0000), success: Color(0xFF32D74B), + onSuccess: Color(0xFFFFFFFF), warning: Color(0xFFFF9F0A), warningContainer: Color(0xFF3D2D00), info: Color(0xFF0A84FF), diff --git a/lib/core/wallet/data/repositories/wallet_repository.dart b/lib/core/wallet/data/repositories/wallet_repository.dart index df61e7419f..cc58043eda 100644 --- a/lib/core/wallet/data/repositories/wallet_repository.dart +++ b/lib/core/wallet/data/repositories/wallet_repository.dart @@ -115,6 +115,7 @@ class WalletRepository { signer: metadata.signer.toEntity(), signerDevice: metadata.signerDevice?.toEntity(), balanceSat: balance.totalSat, + confirmedBalanceSat: balance.confirmedSat, ); } @@ -154,6 +155,7 @@ class WalletRepository { signer: metadata.signer.toEntity(), signerDevice: metadata.signerDevice?.toEntity(), balanceSat: balance.totalSat, + confirmedBalanceSat: balance.confirmedSat, ); } @@ -199,6 +201,7 @@ class WalletRepository { signer: metadata.signer.toEntity(), signerDevice: metadata.signerDevice?.toEntity(), balanceSat: balance.totalSat, + confirmedBalanceSat: balance.confirmedSat, ); } @@ -229,6 +232,7 @@ class WalletRepository { signer: metadata.signer.toEntity(), signerDevice: metadata.signerDevice?.toEntity(), balanceSat: balance.totalSat, + confirmedBalanceSat: balance.confirmedSat, isEncryptedVaultTested: metadata.isEncryptedVaultTested, isPhysicalBackupTested: metadata.isPhysicalBackupTested, latestEncryptedBackup: metadata.latestEncryptedBackup != null @@ -290,6 +294,7 @@ class WalletRepository { signer: entry.value.signer.toEntity(), signerDevice: entry.value.signerDevice?.toEntity(), balanceSat: balances[entry.key].totalSat, + confirmedBalanceSat: balances[entry.key].confirmedSat, isEncryptedVaultTested: entry.value.isEncryptedVaultTested, isPhysicalBackupTested: entry.value.isPhysicalBackupTested, latestEncryptedBackup: entry.value.latestEncryptedBackup != null diff --git a/lib/core/wallet/domain/entities/wallet.dart b/lib/core/wallet/domain/entities/wallet.dart index 00466a5024..1f9783e378 100644 --- a/lib/core/wallet/domain/entities/wallet.dart +++ b/lib/core/wallet/domain/entities/wallet.dart @@ -110,6 +110,15 @@ abstract class Wallet with _$Wallet { required SignerEntity signer, required SignerDeviceEntity? signerDevice, required BigInt balanceSat, + // Confirmed-only component of balanceSat (excludes trusted/untrusted + // pending and immature funds). Nullable/optional so every existing + // construction site doesn't need updating at once; consumers that care + // about "genuinely spendable now" (e.g. payjoin eligibility, which needs + // a real confirmed UTXO to contribute as a proposal input) must treat + // null as "not yet known" rather than falling back to balanceSat, or + // they silently reintroduce the total-vs-confirmed gap this exists to + // close. + BigInt? confirmedBalanceSat, @Default(false) bool isEncryptedVaultTested, @Default(false) bool isPhysicalBackupTested, DateTime? latestEncryptedBackup, diff --git a/lib/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart b/lib/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart new file mode 100644 index 0000000000..d4d7bfae1a --- /dev/null +++ b/lib/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart @@ -0,0 +1,32 @@ +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart'; +import 'package:bb_mobile/core/wallet/domain/wallet_failure.dart'; +import 'package:meta/meta.dart'; + +/// Fetches one wallet transaction by txid. With [sync] the lookup first +/// forces a DIRECT electrum-backed sync of the wallet (the repository's +/// sync path is not routed through the sync coordinator, so it cannot be +/// throttled away) — the way to make a transaction that was just broadcast +/// visible in the local wallet database on demand, instead of waiting for +/// the next organic sync. +class GetWalletTransactionUsecase { + final WalletTransactionRepository _walletTransactionRepository; + + GetWalletTransactionUsecase({required this._walletTransactionRepository}); + + @useResult + Future> execute({ + required String txId, + required String walletId, + bool sync = false, + }) async { + try { + final transaction = await _walletTransactionRepository + .getWalletTransaction(txId, walletId: walletId, sync: sync); + return Ok(transaction); + } catch (e) { + return Err(WalletTransactionLookupFailure(e.toString())); + } + } +} diff --git a/lib/core/wallet/domain/wallet_failure.dart b/lib/core/wallet/domain/wallet_failure.dart new file mode 100644 index 0000000000..32b8a74db1 --- /dev/null +++ b/lib/core/wallet/domain/wallet_failure.dart @@ -0,0 +1,9 @@ +import 'package:bb_mobile/core/failures/failure.dart'; + +sealed class WalletFailure extends Failure { + const WalletFailure([super.logMessage]); +} + +final class WalletTransactionLookupFailure extends WalletFailure { + const WalletTransactionLookupFailure([super.logMessage]); +} diff --git a/lib/core/wallet/wallet_locator.dart b/lib/core/wallet/wallet_locator.dart index b09efc1c93..df5ccf437e 100644 --- a/lib/core/wallet/wallet_locator.dart +++ b/lib/core/wallet/wallet_locator.dart @@ -25,6 +25,7 @@ import 'package:bb_mobile/core/wallet/domain/usecases/create_default_wallets_use import 'package:bb_mobile/core/wallet/domain/usecases/delete_wallet_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_address_at_index_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_receive_address_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/check_liquid_consolidation_usecase.dart'; @@ -193,6 +194,11 @@ class WalletLocator { walletTransactionRepository: locator(), ), ); + locator.registerFactory( + () => GetWalletTransactionUsecase( + walletTransactionRepository: locator(), + ), + ); locator.registerFactory( () => WatchWalletTransactionByAddressUsecase( walletTransactionRepository: locator(), diff --git a/lib/core/widgets/navbar/top_bar.dart b/lib/core/widgets/navbar/top_bar.dart index aca0321027..c348ccf654 100644 --- a/lib/core/widgets/navbar/top_bar.dart +++ b/lib/core/widgets/navbar/top_bar.dart @@ -12,6 +12,7 @@ class TopBar extends StatelessWidget { this.onAction, this.color, this.actionIcon, + this.action, this.bullLogo = false, }); @@ -19,6 +20,11 @@ class TopBar extends StatelessWidget { final Function? onBack; final Function? onAction; final IconData? actionIcon; + + /// Optional custom trailing widget on the right of the bar. Used instead of + /// [onAction]/[actionIcon] when the trailing affordance isn't a plain icon + /// button (e.g. the receive screen's payjoin toggle chip). + final Widget? action; final Color? color; final bool bullLogo; @@ -38,7 +44,7 @@ class TopBar extends StatelessWidget { color: context.appColors.onSurface, visualDensity: VisualDensity.compact, ), - ] else if (onAction != null) + ] else if (onAction != null || action != null) const Gap(40), Expanded( child: Container( @@ -57,7 +63,15 @@ class TopBar extends StatelessWidget { ), ), ), - if (onAction != null) ...[ + if (action != null) ...[ + // No extra bottom padding here (unlike the title's Container, + // which needs it because it has none of its own): the action + // widget is expected to carry its own internal padding, same as + // the plain IconButton case below (Material's default padding), + // so its bottom aligns with the title/icon without a second + // offset stacking on top of it. + action!, + ] else if (onAction != null) ...[ IconButton( icon: Icon(actionIcon ?? Icons.close), onPressed: () => onAction!(), diff --git a/lib/features/announcements/announcements_locator.dart b/lib/features/announcements/announcements_locator.dart new file mode 100644 index 0000000000..46cf1582cd --- /dev/null +++ b/lib/features/announcements/announcements_locator.dart @@ -0,0 +1,55 @@ +import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart'; +import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart'; +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart'; +import 'package:bb_mobile/features/announcements/data/announcement_dismissal_repository_impl.dart'; +import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart'; +import 'package:get_it/get_it.dart'; + +class AnnouncementsLocator { + static void setup(GetIt locator) { + // Data + locator.registerLazySingleton( + () => AnnouncementDismissalDatasource(sqlite: locator()), + ); + locator.registerLazySingleton( + () => AnnouncementDismissalRepositoryImpl( + datasource: locator(), + ), + ); + + // Use-cases + locator.registerFactory( + () => GetVisibleAnnouncementsUsecase( + settingsRepository: locator(), + getWalletTransactionsUsecase: locator(), + getAutoSwapSettingsUsecase: locator(), + dismissalRepository: locator(), + ), + ); + locator.registerFactory( + () => DismissAnnouncementUsecase( + dismissalRepository: locator(), + ), + ); + + // Presentation + locator.registerFactory( + () => AnnouncementsCubit( + getVisibleAnnouncementsUsecase: + locator(), + dismissAnnouncementUsecase: locator(), + watchPayjoinEnabledChangesUsecase: + locator(), + watchFinishedWalletSyncsUsecase: + locator(), + ), + ); + } +} diff --git a/lib/features/announcements/data/announcement_dismissal_repository_impl.dart b/lib/features/announcements/data/announcement_dismissal_repository_impl.dart new file mode 100644 index 0000000000..bad77c560e --- /dev/null +++ b/lib/features/announcements/data/announcement_dismissal_repository_impl.dart @@ -0,0 +1,28 @@ +import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart'; +import 'package:bb_mobile/features/announcements/data/mappers/announcement_dismissal_mapper.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; + +class AnnouncementDismissalRepositoryImpl + implements AnnouncementDismissalRepository { + final AnnouncementDismissalDatasource _datasource; + + AnnouncementDismissalRepositoryImpl({required this._datasource}); + + @override + Future> getDismissals() async { + final models = await _datasource.fetchAll(); + // Drop rows whose id is unknown to this build (forward-compat downgrade). + return models + .map((m) => m.toEntity()) + .whereType() + .toList(); + } + + @override + Future dismiss(AnnouncementId id) async { + // Persist in UTC, per the `dismissed_announcements.dismissedAt` contract. + await _datasource.upsert(id.name, DateTime.now().toUtc()); + } +} diff --git a/lib/features/announcements/data/datasources/announcement_dismissal_datasource.dart b/lib/features/announcements/data/datasources/announcement_dismissal_datasource.dart new file mode 100644 index 0000000000..7c3bd34af2 --- /dev/null +++ b/lib/features/announcements/data/datasources/announcement_dismissal_datasource.dart @@ -0,0 +1,36 @@ +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:bb_mobile/features/announcements/data/models/announcement_dismissal_model.dart'; + +/// Wraps the `dismissed_announcements` Drift table. Private to its repository; +/// speaks the wire/persistence shape (`AnnouncementDismissalModel`), never a +/// domain entity. +class AnnouncementDismissalDatasource { + final SqliteDatabase _sqlite; + + AnnouncementDismissalDatasource({required this._sqlite}); + + Future> fetchAll() async { + final rows = await _sqlite.managers.dismissedAnnouncements.get(); + return rows + .map( + (r) => AnnouncementDismissalModel( + announcementId: r.announcementId, + dismissedAt: r.dismissedAt, + ), + ) + .toList(); + } + + /// Upserts the dismissal: inserts a new row or refreshes the timestamp of an + /// existing one (keyed by [announcementId]). + Future upsert(String announcementId, DateTime dismissedAt) async { + await _sqlite + .into(_sqlite.dismissedAnnouncements) + .insertOnConflictUpdate( + DismissedAnnouncementsCompanion.insert( + announcementId: announcementId, + dismissedAt: dismissedAt, + ), + ); + } +} diff --git a/lib/features/announcements/data/mappers/announcement_dismissal_mapper.dart b/lib/features/announcements/data/mappers/announcement_dismissal_mapper.dart new file mode 100644 index 0000000000..2ffbc74e1e --- /dev/null +++ b/lib/features/announcements/data/mappers/announcement_dismissal_mapper.dart @@ -0,0 +1,17 @@ +import 'package:bb_mobile/features/announcements/data/models/announcement_dismissal_model.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart'; + +/// Translates the persisted dismissal model to the domain entity. +extension AnnouncementDismissalMapper on AnnouncementDismissalModel { + /// Returns the domain entity, or `null` when the stored id is not a known + /// [AnnouncementId] (e.g. a row written by a newer build, then downgraded) — + /// callers skip unknown ids rather than crash. + AnnouncementDismissal? toEntity() { + final id = AnnouncementId.values + .where((v) => v.name == announcementId) + .firstOrNull; + if (id == null) return null; + return AnnouncementDismissal(id: id, dismissedAt: dismissedAt); + } +} diff --git a/lib/features/announcements/data/models/announcement_dismissal_model.dart b/lib/features/announcements/data/models/announcement_dismissal_model.dart new file mode 100644 index 0000000000..d5374fb1c5 --- /dev/null +++ b/lib/features/announcements/data/models/announcement_dismissal_model.dart @@ -0,0 +1,13 @@ +/// Wire/persistence shape of a dismissal record. Pure data — mirrors the +/// `dismissed_announcements` Drift row. Never crosses the repository boundary +/// (the repo maps it to `AnnouncementDismissal`). +class AnnouncementDismissalModel { + /// The `AnnouncementId` enum name as stored. + final String announcementId; + final DateTime dismissedAt; + + const AnnouncementDismissalModel({ + required this.announcementId, + required this.dismissedAt, + }); +} diff --git a/lib/features/announcements/domain/announcements_failure.dart b/lib/features/announcements/domain/announcements_failure.dart new file mode 100644 index 0000000000..7c7135bb99 --- /dev/null +++ b/lib/features/announcements/domain/announcements_failure.dart @@ -0,0 +1,20 @@ +import 'package:bb_mobile/core/failures/failure.dart'; + +/// The announcements feature's sealed failure family (Flutter-free). +/// +/// Its `toTranslated(BuildContext)` lives in the presentation layer +/// (`presentation/announcements_failure_l10n.dart`), never here. +sealed class AnnouncementsFailure extends Failure { + const AnnouncementsFailure([super.logMessage]); +} + +/// A dismissal could not be read from or written to persistent storage. +final class AnnouncementStorageFailure extends AnnouncementsFailure { + const AnnouncementStorageFailure([super.logMessage]); +} + +/// Catch-all for anything not modeled above. The UI renders a generic +/// localized message for this — never the raw [logMessage]. +final class AnnouncementUnexpectedFailure extends AnnouncementsFailure { + const AnnouncementUnexpectedFailure([super.logMessage]); +} diff --git a/lib/features/announcements/domain/entities/announcement.dart b/lib/features/announcements/domain/entities/announcement.dart new file mode 100644 index 0000000000..5c240f642f --- /dev/null +++ b/lib/features/announcements/domain/entities/announcement.dart @@ -0,0 +1,101 @@ +/// A home-screen announcement: a dismissible, tappable nudge shown in the +/// wallet-home carousel (e.g. "Increase privacy with Payjoin"). +/// +/// Announcements are defined in code (compile-time), not persisted — only the +/// per-user *dismissal* fact is stored (see `DismissedAnnouncements` table). +/// The user-facing title/description are NOT held here: they map to +/// localization keys in the presentation layer so `domain/` stays Flutter-free. +library; + +/// Stable identifier for each announcement. The enum *name* is the persistence +/// key (stored in `dismissed_announcements.announcement_id`) and the l10n key +/// prefix, so **never rename or reorder existing values** — only append. +enum AnnouncementId { + /// Shown once the wallet has transaction history and payjoin is disabled, + /// inviting the user to enable payjoin for better on-chain privacy. + payjoinPrivacy, + + /// Shown while autoswap is enabled, so the user is aware it's active and can + /// learn what it does. + autoswapActive, +} + +/// Visual/semantic tone of an announcement, mapped to theme colors in the UI. +enum AnnouncementTone { info, warning, success } + +/// What tapping the announcement's body does. +sealed class AnnouncementAction { + const AnnouncementAction(); +} + +/// Tapping the announcement navigates somewhere. The concrete destination is +/// resolved in the ui layer (`ui/announcement_navigation.dart`) from the +/// [Announcement]'s id, so `domain/` never imports another feature's router. +final class NavigateAction extends AnnouncementAction { + const NavigateAction(); +} + +/// How re-display works after the user dismisses an announcement. +sealed class DismissPolicy { + const DismissPolicy(); +} + +/// Once dismissed, never shown again (until its trigger condition itself +/// changes — which is decided by the trigger, not this policy). +final class PermanentDismiss extends DismissPolicy { + const PermanentDismiss(); +} + +/// Dismissed only temporarily: re-arms (becomes eligible again) once [interval] +/// has elapsed since the dismissal timestamp. +final class SnoozeDismiss extends DismissPolicy { + final Duration interval; + + SnoozeDismiss(this.interval) { + if (interval.inMicroseconds <= 0) { + throw ArgumentError.value( + interval, + 'interval', + 'snooze interval must be positive', + ); + } + } +} + +/// A rich, self-validating announcement definition. +/// +/// Invalid instances are impossible to construct: the id is a closed enum, the +/// action and policy are sealed, and [priority] is validated in the +/// constructor. Ordering in the carousel is by ascending [priority]. +class Announcement { + final AnnouncementId id; + + /// Lower shows first in the carousel. Must be non-negative. + final int priority; + + final AnnouncementTone tone; + final AnnouncementAction action; + final DismissPolicy dismissPolicy; + + Announcement({ + required this.id, + required this.priority, + required this.tone, + required this.action, + required this.dismissPolicy, + }) { + if (priority < 0) { + throw ArgumentError.value(priority, 'priority', 'must be non-negative'); + } + } + + /// Whether a dismissal recorded at [dismissedAt] still suppresses this + /// announcement as of [now]. Permanent dismissals always suppress; snooze + /// dismissals stop suppressing once the interval has elapsed. + bool isSuppressedBy(DateTime dismissedAt, {required DateTime now}) { + return switch (dismissPolicy) { + PermanentDismiss() => true, + SnoozeDismiss(:final interval) => now.isBefore(dismissedAt.add(interval)), + }; + } +} diff --git a/lib/features/announcements/domain/entities/announcement_catalog.dart b/lib/features/announcements/domain/entities/announcement_catalog.dart new file mode 100644 index 0000000000..c31661c76f --- /dev/null +++ b/lib/features/announcements/domain/entities/announcement_catalog.dart @@ -0,0 +1,65 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; + +/// The runtime signals a trigger can read to decide whether it fires. +/// +/// Extend this (and the gathering in `GetVisibleAnnouncementsUsecase`) as new +/// announcements need new signals. +class AnnouncementSignals { + final bool isPayjoinEnabled; + final bool hasTransactionHistory; + final bool isAutoswapEnabled; + + const AnnouncementSignals({ + required this.isPayjoinEnabled, + required this.hasTransactionHistory, + required this.isAutoswapEnabled, + }); +} + +/// A catalog entry: an [Announcement] definition paired with the predicate that +/// decides whether it should appear given the current [AnnouncementSignals]. +class AnnouncementCatalogEntry { + final Announcement announcement; + + /// Predicate deciding whether this announcement should appear given the + /// current signals. Read through [triggersFor]. + final bool Function(AnnouncementSignals signals) trigger; + + const AnnouncementCatalogEntry({ + required this.announcement, + required this.trigger, + }); + + bool triggersFor(AnnouncementSignals signals) => trigger(signals); +} + +/// The single compile-time registry of every home announcement. +/// +/// To add an announcement: append an [AnnouncementId] value, add a catalog +/// entry here with its trigger, and add the title/description l10n mapping in +/// `presentation/announcement_l10n.dart`. +final List announcementCatalog = [ + AnnouncementCatalogEntry( + announcement: Announcement( + id: AnnouncementId.payjoinPrivacy, + priority: 0, + tone: AnnouncementTone.info, + action: const NavigateAction(), + dismissPolicy: const PermanentDismiss(), + ), + // Show once the wallet has received/transacted (first UTXO or history after + // create/recover) AND payjoin is still off — nudging the privacy upgrade. + trigger: (s) => s.hasTransactionHistory && !s.isPayjoinEnabled, + ), + AnnouncementCatalogEntry( + announcement: Announcement( + id: AnnouncementId.autoswapActive, + priority: 1, + tone: AnnouncementTone.success, + action: const NavigateAction(), + dismissPolicy: const PermanentDismiss(), + ), + // Show while autoswap is enabled, letting the user learn what it does. + trigger: (s) => s.isAutoswapEnabled, + ), +]; diff --git a/lib/features/announcements/domain/entities/announcement_dismissal.dart b/lib/features/announcements/domain/entities/announcement_dismissal.dart new file mode 100644 index 0000000000..9d519e716f --- /dev/null +++ b/lib/features/announcements/domain/entities/announcement_dismissal.dart @@ -0,0 +1,12 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; + +/// A record that the user dismissed a given announcement at a given time. +/// +/// Domain entity handed out by `AnnouncementDismissalRepository`; the wire/DB +/// shape lives in `data/models/` and never crosses the repository boundary. +class AnnouncementDismissal { + final AnnouncementId id; + final DateTime dismissedAt; + + AnnouncementDismissal({required this.id, required this.dismissedAt}); +} diff --git a/lib/features/announcements/domain/repositories/announcement_dismissal_repository.dart b/lib/features/announcements/domain/repositories/announcement_dismissal_repository.dart new file mode 100644 index 0000000000..8576fb51cf --- /dev/null +++ b/lib/features/announcements/domain/repositories/announcement_dismissal_repository.dart @@ -0,0 +1,15 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart'; + +/// The single source of truth for which announcements the user has dismissed. +/// +/// Returns and accepts only domain types (never a Drift row / model). The +/// implementation lives in `data/` and wraps the `dismissed_announcements` +/// table. +abstract interface class AnnouncementDismissalRepository { + /// All recorded dismissals, keyed by announcement id. + Future> getDismissals(); + + /// Records (or refreshes) a dismissal for [id] at the current time. + Future dismiss(AnnouncementId id); +} diff --git a/lib/features/announcements/domain/usecases/dismiss_announcement_usecase.dart b/lib/features/announcements/domain/usecases/dismiss_announcement_usecase.dart new file mode 100644 index 0000000000..845ff9feee --- /dev/null +++ b/lib/features/announcements/domain/usecases/dismiss_announcement_usecase.dart @@ -0,0 +1,21 @@ +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; + +/// Records that the user dismissed an announcement, so it stops showing +/// (permanently or until its snooze interval elapses, per its dismiss policy). +class DismissAnnouncementUsecase { + final AnnouncementDismissalRepository _dismissalRepository; + + DismissAnnouncementUsecase({required this._dismissalRepository}); + + Future> execute(AnnouncementId id) async { + try { + await _dismissalRepository.dismiss(id); + return const Ok(null); + } catch (e) { + return Err(AnnouncementStorageFailure(e.toString())); + } + } +} diff --git a/lib/features/announcements/domain/usecases/get_visible_announcements_usecase.dart b/lib/features/announcements/domain/usecases/get_visible_announcements_usecase.dart new file mode 100644 index 0000000000..28bb43a275 --- /dev/null +++ b/lib/features/announcements/domain/usecases/get_visible_announcements_usecase.dart @@ -0,0 +1,71 @@ +import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart'; +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement_catalog.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; + +/// Orchestrates which announcements are currently visible on the home carousel. +/// +/// Thin orchestration only: it gathers the trigger signals (payjoin setting, +/// whether the wallet has transaction history), asks each catalog entry whether +/// its trigger fires, drops anything the user has dismissed (respecting the +/// per-announcement dismiss policy), and returns the survivors ordered by +/// ascending priority. All decision *rules* live on the entities / catalog; +/// this use-case only wires signals to them. +class GetVisibleAnnouncementsUsecase { + final SettingsRepository _settingsRepository; + final GetWalletTransactionsUsecase _getWalletTransactionsUsecase; + final GetAutoSwapSettingsUsecase _getAutoSwapSettingsUsecase; + final AnnouncementDismissalRepository _dismissalRepository; + + GetVisibleAnnouncementsUsecase({ + required this._settingsRepository, + required this._getWalletTransactionsUsecase, + required this._getAutoSwapSettingsUsecase, + required this._dismissalRepository, + }); + + Future, AnnouncementsFailure>> execute() async { + try { + // The four sources are independent, so gather them concurrently. + final (settings, transactions, autoSwap, dismissals) = await ( + _settingsRepository.fetch(), + _getWalletTransactionsUsecase.execute(), + _getAutoSwapSettingsUsecase.execute(), + _dismissalRepository.getDismissals(), + ).wait; + + final signals = AnnouncementSignals( + isPayjoinEnabled: settings.isPayjoinEnabled, + hasTransactionHistory: transactions.isNotEmpty, + isAutoswapEnabled: autoSwap.enabled, + ); + + final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt}; + final now = DateTime.now().toUtc(); + + final visible = []; + for (final entry in announcementCatalog) { + if (!entry.triggersFor(signals)) continue; + + final dismissedAt = dismissedAtById[entry.announcement.id]; + final suppressed = + dismissedAt != null && + entry.announcement.isSuppressedBy(dismissedAt, now: now); + if (suppressed) continue; + + visible.add(entry.announcement); + } + + visible.sort((a, b) => a.priority.compareTo(b.priority)); + return Ok(visible); + } catch (e) { + // Sources span settings/tx/autoswap/storage, so this is a genuine + // catch-all rather than a storage-only failure. + return Err(AnnouncementUnexpectedFailure(e.toString())); + } + } +} diff --git a/lib/features/announcements/presentation/announcement_l10n.dart b/lib/features/announcements/presentation/announcement_l10n.dart new file mode 100644 index 0000000000..7158cc448e --- /dev/null +++ b/lib/features/announcements/presentation/announcement_l10n.dart @@ -0,0 +1,20 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:flutter/widgets.dart'; + +/// Localized user-facing content for each announcement. Kept in presentation so +/// the domain stays Flutter-free; the `sealed`-like exhaustive switch over the +/// closed [AnnouncementId] enum makes a missing string a compile-time warning +/// (unhandled enum value). +extension AnnouncementL10n on Announcement { + String title(BuildContext context) => switch (id) { + AnnouncementId.payjoinPrivacy => context.loc.announcementPayjoinTitle, + AnnouncementId.autoswapActive => context.loc.announcementAutoswapTitle, + }; + + String description(BuildContext context) => switch (id) { + AnnouncementId.payjoinPrivacy => context.loc.announcementPayjoinDescription, + AnnouncementId.autoswapActive => + context.loc.announcementAutoswapDescription, + }; +} diff --git a/lib/features/announcements/presentation/announcements_cubit.dart b/lib/features/announcements/presentation/announcements_cubit.dart new file mode 100644 index 0000000000..245a9b95b1 --- /dev/null +++ b/lib/features/announcements/presentation/announcements_cubit.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'announcements_cubit.freezed.dart'; +part 'announcements_state.dart'; + +/// Thin presentation seam for the home announcements carousel. +/// +/// It only: loads the currently-visible announcements, re-evaluates when a +/// relevant signal changes (payjoin toggled elsewhere, or a wallet finishing a +/// sync — which is when transaction history first appears after a recovery), +/// and records dismissals. All decision logic lives in the use-cases / +/// entities. +class AnnouncementsCubit extends Cubit { + final GetVisibleAnnouncementsUsecase _getVisibleAnnouncementsUsecase; + final DismissAnnouncementUsecase _dismissAnnouncementUsecase; + final WatchPayjoinEnabledChangesUsecase _watchPayjoinEnabledChangesUsecase; + final WatchFinishedWalletSyncsUsecase _watchFinishedWalletSyncsUsecase; + + StreamSubscription? _payjoinEnabledSub; + StreamSubscription? _walletSyncSub; + + bool _refreshing = false; + bool _refreshQueued = false; + + AnnouncementsCubit({ + required this._getVisibleAnnouncementsUsecase, + required this._dismissAnnouncementUsecase, + required this._watchPayjoinEnabledChangesUsecase, + required this._watchFinishedWalletSyncsUsecase, + }) : super(const AnnouncementsState()) { + _payjoinEnabledSub = _watchPayjoinEnabledChangesUsecase.execute().listen( + (_) => refresh(), + ); + // Re-evaluate after each wallet sync: a freshly recovered wallet only gets + // its transaction history (the payjoin-privacy trigger) once it syncs. + _walletSyncSub = _watchFinishedWalletSyncsUsecase.execute().listen( + (_) => refresh(), + ); + } + + /// (Re)loads the visible announcements. Called on mount and whenever a + /// trigger signal changes. + /// + /// Overlapping calls are coalesced: a request arriving while a load is in + /// flight re-runs once after it completes, so several wallets syncing + /// back-to-back can't spawn redundant, out-of-order loads. + Future refresh() async { + if (_refreshing) { + _refreshQueued = true; + return; + } + _refreshing = true; + try { + do { + _refreshQueued = false; + final result = await _getVisibleAnnouncementsUsecase.execute(); + if (isClosed) return; + result.fold( + (announcements) => + emit(AnnouncementsState(announcements: announcements)), + (failure) => emit(state.copyWith(failure: failure)), + ); + } while (_refreshQueued); + } finally { + _refreshing = false; + } + } + + /// Records a dismissal and refreshes the list (which collapses the section + /// when the last card is dismissed). + Future dismiss(AnnouncementId id) async { + final result = await _dismissAnnouncementUsecase.execute(id); + if (isClosed) return; + await result.fold( + (_) => refresh(), + (failure) async => emit(state.copyWith(failure: failure)), + ); + } + + @override + Future close() { + _payjoinEnabledSub?.cancel(); + _walletSyncSub?.cancel(); + return super.close(); + } +} diff --git a/lib/features/announcements/presentation/announcements_failure_l10n.dart b/lib/features/announcements/presentation/announcements_failure_l10n.dart new file mode 100644 index 0000000000..1859773e4f --- /dev/null +++ b/lib/features/announcements/presentation/announcements_failure_l10n.dart @@ -0,0 +1,13 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart'; +import 'package:flutter/widgets.dart'; + +/// User-facing, localized message for each [AnnouncementsFailure]. The `sealed` +/// switch makes a missing message a compile error. Never returns the raw +/// `logMessage`. +extension AnnouncementsFailureL10n on AnnouncementsFailure { + String toTranslated(BuildContext context) => switch (this) { + AnnouncementStorageFailure() => context.loc.oopsSomethingWentWrong, + AnnouncementUnexpectedFailure() => context.loc.oopsSomethingWentWrong, + }; +} diff --git a/lib/features/announcements/presentation/announcements_state.dart b/lib/features/announcements/presentation/announcements_state.dart new file mode 100644 index 0000000000..26f8f24800 --- /dev/null +++ b/lib/features/announcements/presentation/announcements_state.dart @@ -0,0 +1,15 @@ +part of 'announcements_cubit.dart'; + +@freezed +sealed class AnnouncementsState with _$AnnouncementsState { + const AnnouncementsState._(); + + const factory AnnouncementsState({ + @Default([]) List announcements, + AnnouncementsFailure? failure, + }) = _AnnouncementsState; + + /// The section renders only when there's at least one announcement to show; + /// the home carousel collapses to nothing otherwise. + bool get hasVisibleAnnouncements => announcements.isNotEmpty; +} diff --git a/lib/features/announcements/ui/announcement_navigation.dart b/lib/features/announcements/ui/announcement_navigation.dart new file mode 100644 index 0000000000..bbb28be1ca --- /dev/null +++ b/lib/features/announcements/ui/announcement_navigation.dart @@ -0,0 +1,14 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/settings/ui/settings_router.dart'; + +/// Resolves each announcement to the route its [NavigateAction] opens. +/// +/// Lives in the ui layer so `domain/` never imports another feature's router +/// (AGENTS.md rule #1 + Flutter-free domain). The exhaustive `switch` over the +/// closed [AnnouncementId] enum makes a missing mapping a compile-time warning. +extension AnnouncementNavigation on Announcement { + SettingsRoute get route => switch (id) { + AnnouncementId.payjoinPrivacy => SettingsRoute.payjoinSettings, + AnnouncementId.autoswapActive => SettingsRoute.autoswapSettings, + }; +} diff --git a/lib/features/announcements/ui/widgets/announcement_card.dart b/lib/features/announcements/ui/widgets/announcement_card.dart new file mode 100644 index 0000000000..c5a28f0af3 --- /dev/null +++ b/lib/features/announcements/ui/widgets/announcement_card.dart @@ -0,0 +1,59 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcement_l10n.dart'; +import 'package:bull_ui/bull_ui.dart'; + +/// A single announcement banner: a tappable [BullInfoCard] body (fires the +/// announcement's action) with an explicit `×` dismiss button in the corner. +/// +/// The `×` is a separate hit target from the body, so paging/tapping and +/// dismissing never conflict. +class AnnouncementCard extends StatelessWidget { + const AnnouncementCard({ + super.key, + required this.announcement, + required this.onTap, + required this.onDismiss, + }); + + final Announcement announcement; + final VoidCallback onTap; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final colors = context.bull; + final tone = switch (announcement.tone) { + AnnouncementTone.info => colors.info, + AnnouncementTone.warning => colors.warning, + AnnouncementTone.success => colors.success, + }; + + return Stack( + children: [ + BullInfoCard( + title: announcement.title(context), + description: announcement.description(context), + tagColor: tone, + bgColor: tone.withValues(alpha: 0.12), + onTap: onTap, + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onDismiss, + child: Padding( + padding: const EdgeInsets.all(6), + child: BullIcon( + BullIcons.close, + size: 18, + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/announcements/ui/widgets/announcement_carousel.dart b/lib/features/announcements/ui/widgets/announcement_carousel.dart new file mode 100644 index 0000000000..4877e6ccff --- /dev/null +++ b/lib/features/announcements/ui/widgets/announcement_carousel.dart @@ -0,0 +1,200 @@ +import 'package:bb_mobile/core/widgets/snackbar_utils.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcements_failure_l10n.dart'; +import 'package:bb_mobile/features/announcements/ui/announcement_navigation.dart'; +import 'package:bb_mobile/features/announcements/ui/widgets/announcement_card.dart'; +import 'package:bb_mobile/features/announcements/ui/widgets/announcement_dismiss_dialog.dart'; +import 'package:bull_ui/bull_ui.dart'; +import 'package:flutter/widgets.dart' show MediaQuery; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +/// The home-screen announcements section: a paged carousel of dismissible +/// banners with a dot indicator. Renders nothing (zero height) when there are +/// no visible announcements — including the moment the user dismisses the last +/// one, which animates the section closed. +class AnnouncementCarousel extends StatelessWidget { + const AnnouncementCarousel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + // Surface a dismissal/refresh failure (the card otherwise just stays). + // Fires only when a new failure appears, not on every rebuild. + listenWhen: (previous, current) => + current.failure != null && previous.failure != current.failure, + listener: (context, state) => SnackBarUtils.showSnackBar( + context, + state.failure!.toTranslated(context), + ), + // Narrow rebuild: only when the visible set changes. + child: + BlocSelector< + AnnouncementsCubit, + AnnouncementsState, + List + >( + selector: (state) => state.announcements, + builder: (context, announcements) { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: announcements.isEmpty + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.only( + left: 13, + right: 13, + top: 13, + ), + child: _CarouselBody(announcements: announcements), + ), + ); + }, + ), + ); + } +} + +class _CarouselBody extends StatefulWidget { + const _CarouselBody({required this.announcements}); + + final List announcements; + + @override + State<_CarouselBody> createState() => _CarouselBodyState(); +} + +class _CarouselBodyState extends State<_CarouselBody> { + /// Base height (at textScale 1.0) that fits a two-line title+description card + /// plus the reserved dots strip at the bottom. Scales with the user's text + /// size so larger accessibility settings never overflow. + static const double _baseCardHeight = 112; + + late final PageController _controller; + int _page = 0; + + @override + void initState() { + super.initState(); + _controller = PageController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTap(Announcement announcement) { + switch (announcement.action) { + case NavigateAction(): + context.pushNamed(announcement.route.name); + } + } + + Future _onDismiss(Announcement announcement) async { + final cubit = context.read(); + final choice = await AnnouncementDismissDialog.show(context); + switch (choice) { + case AnnouncementDismissChoice.read: + if (mounted) _onTap(announcement); + case AnnouncementDismissChoice.dismiss: + await cubit.dismiss(announcement.id); + case null: + break; + } + } + + @override + Widget build(BuildContext context) { + final announcements = widget.announcements; + // Keep the active dot in range if the list shrank after a dismiss. + final activePage = _page.clamp(0, announcements.length - 1); + final showDots = announcements.length > 1; + + // Adapt to the user's text-scale setting so the card grows with larger + // accessibility font sizes instead of overflowing. + final textScale = MediaQuery.textScalerOf(context).scale(1); + final cardHeight = _baseCardHeight * textScale; + + return SizedBox( + height: cardHeight, + child: Stack( + children: [ + PageView.builder( + controller: _controller, + itemCount: announcements.length, + onPageChanged: (i) => setState(() => _page = i), + itemBuilder: (context, index) { + final announcement = announcements[index]; + // Reserve the dots strip at the bottom so the card's centered + // content never collides with the indicator. + return Padding( + padding: EdgeInsets.only(bottom: showDots ? 22 : 0), + child: AnnouncementCard( + announcement: announcement, + onTap: () => _onTap(announcement), + onDismiss: () => _onDismiss(announcement), + ), + ); + }, + ), + // Dots sit inside the card, bottom-centered, so they clearly belong + // to the carousel rather than floating below it. + if (showDots) + Positioned( + left: 0, + right: 0, + bottom: 8, + child: _Dots(count: announcements.length, active: activePage), + ), + ], + ), + ); + } +} + +class _Dots extends StatelessWidget { + const _Dots({required this.count, required this.active}); + + final int count; + final int active; + + @override + Widget build(BuildContext context) { + final colors = context.bull; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < count; i++) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: i == active ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: i == active + ? colors.primary + : colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/announcements/ui/widgets/announcement_dismiss_dialog.dart b/lib/features/announcements/ui/widgets/announcement_dismiss_dialog.dart new file mode 100644 index 0000000000..41a7599725 --- /dev/null +++ b/lib/features/announcements/ui/widgets/announcement_dismiss_dialog.dart @@ -0,0 +1,79 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bull_ui/bull_ui.dart'; +import 'package:flutter/material.dart'; + +/// Outcome of the announcement dismiss dialog. +enum AnnouncementDismissChoice { + /// Open the announcement's linked page (same as tapping the card). + read, + + /// Dismiss the announcement. + dismiss, +} + +/// Dialog shown when the user taps the `×` on an announcement. Offers to either +/// read (open the linked page) or dismiss it. Returns `null` if cancelled +/// (barrier tap). +abstract final class AnnouncementDismissDialog { + static Future show(BuildContext context) { + return BullDialog.show( + context: context, + builder: (dialogContext) => _AnnouncementDismissDialogBody(dialogContext), + ); + } +} + +class _AnnouncementDismissDialogBody extends StatelessWidget { + const _AnnouncementDismissDialogBody(this.dialogContext); + + final BuildContext dialogContext; + + @override + Widget build(BuildContext context) { + final colors = context.bull; + final textTheme = Theme.of(context).textTheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.loc.announcementDismissConfirmTitle, + style: textTheme.titleMedium?.copyWith(color: colors.text), + ), + const Gap(8), + Text( + context.loc.announcementDismissConfirmBody, + style: textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + const Gap(20), + Row( + children: [ + Expanded( + child: BullButton.small( + label: context.loc.announcementDismissConfirmRead, + onPressed: () => Navigator.of( + dialogContext, + ).pop(AnnouncementDismissChoice.read), + bgColor: colors.surface, + textColor: colors.text, + outlined: true, + borderColor: colors.outline, + ), + ), + const Gap(12), + Expanded( + child: BullButton.small( + label: context.loc.announcementDismissConfirmAction, + onPressed: () => Navigator.of( + dialogContext, + ).pop(AnnouncementDismissChoice.dismiss), + bgColor: colors.error, + textColor: colors.onError, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/receive/presentation/bloc/receive_bloc.dart b/lib/features/receive/presentation/bloc/receive_bloc.dart index 5440c2f7a8..f3990033b7 100644 --- a/lib/features/receive/presentation/bloc/receive_bloc.dart +++ b/lib/features/receive/presentation/bloc/receive_bloc.dart @@ -10,6 +10,7 @@ import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dar import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart'; import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart'; +import 'package:bb_mobile/features/settings/domain/usecases/set_payjoin_enabled_usecase.dart'; import 'package:bb_mobile/core/swaps/domain/entity/swap.dart'; import 'package:bb_mobile/core/swaps/domain/usecases/get_swap_limits_usecase.dart'; import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart'; @@ -29,6 +30,7 @@ import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_b import 'package:bb_mobile/features/labels/labels_facade.dart'; import 'package:bb_mobile/features/receive/domain/usecases/create_receive_swap_use_case.dart'; import 'package:bb_mobile/features/transactions/domain/entities/transaction.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -48,11 +50,12 @@ class ReceiveBloc extends Bloc { required this._receiveWithPayjoinUsecase, required this._broadcastOriginalTransactionUsecase, required this._watchPayjoinUsecase, - required this._watchPayjoinEnabledChangesUsecase, required this._watchWalletTransactionByAddressUsecase, required this._watchSwapUsecase, required this._labelsFacade, required this._getSwapLimitsUsecase, + required this._watchPayjoinEnabledChangesUsecase, + required this._setPayjoinEnabledUsecase, this._wallet, }) : super(const ReceiveState()) { on(_onBitcoinStarted); @@ -63,13 +66,38 @@ class ReceiveBloc extends Bloc { on(_onAmountCurrencyChanged); on(_onNoteChanged); on(_onNoteSaved); - on(_onAddressOnlyToggled); on(_onNewAddressGenerated); on(_onPayjoinUpdated); - on(_onPayjoinEnabledChanged); on(_onPayjoinOriginalTxBroadcasted); on(_onReceiveTransactionReceived); on(_onLightningSwapUpdated); + // restartable(): a rapid on/off/on toggle must not let a stale event's + // in-flight session creation land after a newer event already decided + // the opposite outcome — restartable() drops a handler's own emit() + // calls once a newer ReceivePayjoinSettingChanged has started (same + // pattern as RecipientsBloc's search debouncing). Combined with the + // explicit re-check inside the handler for defense in depth. + on( + _onPayjoinSettingChanged, + transformer: restartable(), + ); + on(_onPayjoinToggled); + + // Live-react to the global payjoin setting changing anywhere in the app + // (e.g. the settings screen), instead of only reading it once when the + // bitcoin receive route is entered — otherwise a receive screen left + // open while the user flips the toggle in Settings never picks it up + // (observed live: enabling payjoin globally did nothing to an + // already-open receive screen). + _payjoinSettingChangeSubscription = _watchPayjoinEnabledChangesUsecase + .execute() + .listen((enabled) { + // The repository's broadcast stream outlives this bloc; never add + // to a closed bloc (it throws). + if (isClosed) return; + log.info('[ReceiveBloc] Payjoin globally enabled changed: $enabled'); + add(ReceivePayjoinSettingChanged(enabled)); + }); } final GetWalletsUsecase _getWalletsUsecase; @@ -83,29 +111,42 @@ class ReceiveBloc extends Bloc { _broadcastOriginalTransactionUsecase; final CreateReceiveSwapUsecase _createReceiveSwapUsecase; final WatchPayjoinUsecase _watchPayjoinUsecase; - final WatchPayjoinEnabledChangesUsecase _watchPayjoinEnabledChangesUsecase; final WatchWalletTransactionByAddressUsecase _watchWalletTransactionByAddressUsecase; final WatchSwapUsecase _watchSwapUsecase; final LabelsFacade _labelsFacade; final GetSwapLimitsUsecase _getSwapLimitsUsecase; + final WatchPayjoinEnabledChangesUsecase _watchPayjoinEnabledChangesUsecase; + final SetPayjoinEnabledUsecase _setPayjoinEnabledUsecase; final Wallet? _wallet; StreamSubscription? _payjoinSubscription; - StreamSubscription? _payjoinEnabledSubscription; StreamSubscription? _walletTransactionSubscription; StreamSubscription? _swapSubscription; + late final StreamSubscription _payjoinSettingChangeSubscription; @override Future close() async { await Future.wait([ _payjoinSubscription?.cancel() ?? Future.value(), - _payjoinEnabledSubscription?.cancel() ?? Future.value(), _walletTransactionSubscription?.cancel() ?? Future.value(), _swapSubscription?.cancel() ?? Future.value(), + _payjoinSettingChangeSubscription.cancel(), ]); return super.close(); } + /// Whether a payjoin receiver session should exist for [wallet] right now: + /// it must be able to sign locally (payjoin needs to sign a proposal + /// non-interactively), the global setting must be on, AND the wallet must + /// already have a confirmed balance — a payjoin proposal needs at least + /// one UTXO to contribute as an input, so creating (and then polling for + /// requests on) a session that could never actually payjoin is pointless + /// and needlessly exposes the anti-probing surface for no benefit. + bool _isPayjoinEligible(Wallet wallet, bool payjoinEnabled) => + wallet.signsLocally && + payjoinEnabled && + (wallet.confirmedBalanceSat ?? BigInt.zero) > BigInt.zero; + Future _onBitcoinStarted( ReceiveBitcoinStarted event, Emitter emit, @@ -193,19 +234,43 @@ class ReceiveBloc extends Bloc { emit(state.copyWith(bitcoinAddress: bitcoinAddress, note: note)); } - // Track whether payjoin is enabled in settings, and start reacting live - // to the user flipping it while this screen stays open (see - // _onPayjoinEnabledChanged). - final settings = await _getSettingsUsecase.execute(); - emit(state.copyWith(isPayjoinEnabled: settings.isPayjoinEnabled)); - _watchPayjoinEnabledChanges(); - // If the payjoin receiver is not set yet, we need to create it, but only - // if the wallet is not watch only. If the wallet is watch only, we - // shouldn't create a payjoin receiver since we can't sign proposals - // non-interactively. Whether payjoin is enabled in settings is the - // usecase's call — it returns null when disabled. - if (state.payjoin == null && wallet.signsLocally) { + // if the wallet is eligible (see _isPayjoinEligible: not watch-only, + // payjoin enabled globally, and a confirmed balance to contribute) — + // when disabled the QR must never advertise a pj= endpoint, or the + // sender's wallet would attempt a payjoin nobody here will process. + // + // Isolated in its own try/catch: a settings-read failure here must not + // leave payjoinGloballyEnabled at its default null. isPayjoinLoading's + // `(payjoinGloballyEnabled ?? true)` treats null as "may still become + // enabled, keep waiting" — so an uncaught failure here would leave the + // QR stuck loading forever, the exact failure class this whole gate + // exists to prevent, just via a different entrance. Fail closed + // (disabled) on a read failure instead. + bool payjoinEnabled; + int? payjoinMinAmountSat; + try { + final settings = await _getSettingsUsecase.execute(); + payjoinEnabled = settings.isPayjoinEnabled; + payjoinMinAmountSat = settings.payjoinMinAmountSat; + } catch (e) { + log.warning( + 'Failed to read payjoin settings; treating as disabled: $e', + ); + payjoinEnabled = false; + payjoinMinAmountSat = null; + } + // The state must know the setting: ReceiveState.isPayjoinLoading (and + // through it the QR's paymentRequest) waits for a payjoin session + // unless it can see payjoin is disabled. payjoinMinAmountSat is carried + // too so the in-progress screen can explain a below-minimum decline. + emit( + state.copyWith( + payjoinGloballyEnabled: payjoinEnabled, + payjoinMinAmountSat: payjoinMinAmountSat, + ), + ); + if (state.payjoin == null && _isPayjoinEligible(wallet, payjoinEnabled)) { PayjoinReceiver? payjoin; Object? error; try { @@ -214,9 +279,7 @@ class ReceiveBloc extends Bloc { address: bitcoinAddress.address, ); // The payjoin receiver is created, now we can watch it for updates - if (payjoin != null) { - _watchPayjoin(payjoin.id); - } + _watchPayjoin(payjoin.id); } catch (e) { log.severe( message: 'Payjoin receiver creation failed', @@ -236,12 +299,10 @@ class ReceiveBloc extends Bloc { ), ); } else if (state.payjoin != null && - (!wallet.signsLocally || !settings.isPayjoinEnabled)) { - // If the wallet is watch only, or payjoin was disabled in settings - // while this screen wasn't open to react live (_onPayjoinEnabledChanged - // handles the live case), we - // need to clear the payjoin receiver since we either can't sign - // proposals non-interactively, or the user opted out. + !_isPayjoinEligible(wallet, payjoinEnabled)) { + // If the wallet is watch only, payjoin was turned off since we last + // created a receiver, or the wallet no longer has a confirmed + // balance to contribute, clear it. emit(state.copyWith(payjoin: null)); // cancel the payjoin subscription as well if it exists await _payjoinSubscription?.cancel(); @@ -676,16 +737,6 @@ class ReceiveBloc extends Bloc { } } - Future _onAddressOnlyToggled( - ReceiveAddressOnlyToggled event, - Emitter emit, - ) async { - // This toggle switch button is only available in the bitcoin receive screen - if (state.type == ReceiveType.bitcoin) { - emit(state.copyWith(isAddressOnly: event.isAddressOnly)); - } - } - Future _onNewAddressGenerated( ReceiveNewAddressGenerated event, Emitter emit, @@ -717,19 +768,38 @@ class ReceiveBloc extends Bloc { generateNew: true, ); // If a new address is generated, we need to update the payjoin - // receiver as well, but only if the wallet is not watch only. - // Whether payjoin is enabled in settings is the usecase's call — - // it returns null when disabled. - if (state.wallet!.signsLocally) { + // receiver as well, but only if the wallet is eligible (see + // _isPayjoinEligible / _onBitcoinStarted). + // + // Same fail-closed handling as _onBitcoinStarted: a settings-read + // failure must not leave isPayjoinLoading waiting forever. + bool payjoinEnabled; + int? payjoinMinAmountSat; + try { + final settings = await _getSettingsUsecase.execute(); + payjoinEnabled = settings.isPayjoinEnabled; + payjoinMinAmountSat = settings.payjoinMinAmountSat; + } catch (e) { + log.warning( + 'Failed to read payjoin settings; treating as disabled: $e', + ); + payjoinEnabled = false; + payjoinMinAmountSat = null; + } + emit( + state.copyWith( + payjoinGloballyEnabled: payjoinEnabled, + payjoinMinAmountSat: payjoinMinAmountSat, + ), + ); + if (_isPayjoinEligible(state.wallet!, payjoinEnabled)) { try { payjoin = await _receiveWithPayjoinUsecase.execute( walletId: walletId, address: address.address, ); // The payjoin receiver is created, now we can watch it for updates - if (payjoin != null) { - _watchPayjoin(payjoin.id); - } + _watchPayjoin(payjoin.id); } catch (e) { log.severe( message: 'Payjoin receiver creation failed', @@ -744,7 +814,15 @@ class ReceiveBloc extends Bloc { state.copyWith( bitcoinAddress: address, payjoin: payjoin, - error: error, + // Split the error the same way _onBitcoinStarted does: a + // ReceivePayjoinException must land in receivePayjoinException, + // not the generic error slot. Otherwise isPayjoinLoading stays + // true forever (payjoin == null && receivePayjoinException == + // null) and the QR never resolves. + error: error is! ReceivePayjoinException ? error : null, + receivePayjoinException: error is ReceivePayjoinException + ? error + : null, ), ); @@ -782,61 +860,88 @@ class ReceiveBloc extends Bloc { } } - Future _onPayjoinEnabledChanged( - ReceivePayjoinEnabledChanged event, + /// Reacts live to the global payjoin setting changing (see the + /// constructor's subscription): creates or clears the payjoin receiver + /// session for the CURRENTLY displayed bitcoin address without requiring + /// the user to leave and re-enter the receive screen. A no-op outside the + /// bitcoin flow, or before a wallet/address is loaded — _onBitcoinStarted + /// picks up the freshly-read setting on the next entry regardless. + /// User tapped the payjoin toggle on the receive screen. Persists the new + /// value to the GLOBAL setting; the resulting payjoinEnabledChangeStream + /// event flows back in as [ReceivePayjoinSettingChanged], which does the + /// actual session create/clear — so this single write drives both the + /// receive screen and anything else listening to the setting. + Future _onPayjoinToggled( + ReceivePayjoinToggled event, Emitter emit, ) async { - if (state.type != ReceiveType.bitcoin) return; - - emit(state.copyWith(isPayjoinEnabled: event.isEnabled)); - - if (!event.isEnabled) { - // Payjoin was turned off: drop the current receiver and stop watching - // it, same as the watch-only case in _onBitcoinStarted. - await _payjoinSubscription?.cancel(); - emit(state.copyWith(payjoin: null)); - return; + try { + await _setPayjoinEnabledUsecase.execute(event.enabled); + } catch (e) { + log.severe( + message: 'Failed to toggle payjoin from the receive screen', + error: e, + trace: StackTrace.current, + ); } + } + + Future _onPayjoinSettingChanged( + ReceivePayjoinSettingChanged event, + Emitter emit, + ) async { + emit(state.copyWith(payjoinGloballyEnabled: event.enabled)); final wallet = state.wallet; - final address = state.bitcoinAddress; - if (wallet == null || - address == null || - !wallet.signsLocally || - state.payjoin != null) { + final bitcoinAddress = state.bitcoinAddress; + if (state.type != ReceiveType.bitcoin || + wallet == null || + bitcoinAddress == null) { return; } - // Payjoin was turned on while this screen is open: create a receiver - // for the address already on display. - PayjoinReceiver? payjoin; - Object? error; - try { - payjoin = await _receiveWithPayjoinUsecase.execute( - walletId: wallet.id, - address: address.address, - ); - if (payjoin != null) { + if (state.payjoin == null && _isPayjoinEligible(wallet, event.enabled)) { + PayjoinReceiver? payjoin; + Object? error; + try { + payjoin = await _receiveWithPayjoinUsecase.execute( + walletId: wallet.id, + address: bitcoinAddress.address, + ); + // Belt-and-suspenders alongside restartable(): the setting could have + // changed again while the creation above was in flight (restartable() + // guards this handler's own emit() calls once a newer + // ReceivePayjoinSettingChanged starts, but doesn't stop the code + // running up to that point). Re-check against the CURRENT state + // before arming the watcher / emitting the session — otherwise a + // stale "enable" outcome could still surface a payjoin session (and + // a live watcher for it) after the setting was flipped back off. + if (state.payjoinGloballyEnabled != event.enabled) { + return; + } _watchPayjoin(payjoin.id); + } catch (e) { + log.severe( + message: 'Payjoin receiver creation failed', + error: e, + trace: StackTrace.current, + ); + error = e; } - } catch (e) { - log.severe( - message: 'Payjoin receiver creation failed', - error: e, - trace: StackTrace.current, + emit( + state.copyWith( + payjoin: payjoin, + error: error is! ReceivePayjoinException ? error : null, + receivePayjoinException: error is ReceivePayjoinException + ? error + : null, + ), ); - error = e; + } else if (state.payjoin != null && + !_isPayjoinEligible(wallet, event.enabled)) { + await _payjoinSubscription?.cancel(); + emit(state.copyWith(payjoin: null)); } - - emit( - state.copyWith( - payjoin: payjoin, - error: error is! ReceivePayjoinException ? error : null, - receivePayjoinException: error is ReceivePayjoinException - ? error - : null, - ), - ); } Future _onPayjoinOriginalTxBroadcasted( @@ -844,9 +949,17 @@ class ReceiveBloc extends Bloc { Emitter emit, ) async { final payjoin = state.payjoin; + // canManuallyBroadcastOriginal is a backstop against a stale UI snapshot: + // once a proposal is sent (or the session resolved), broadcasting the + // original would race the sender's payjoin transaction spending the same + // inputs — either a no-op rejected by RBF's insufficient-fee rule + // (observed live) or, worse, replacing a payment that already carried a + // privacy benefit. The button's visibility is gated on the same getter, + // so this can never disagree with what the UI showed. if (state.type == ReceiveType.bitcoin && payjoin != null && - payjoin.originalTxBytes != null) { + payjoin.originalTxBytes != null && + payjoin.canManuallyBroadcastOriginal) { try { emit(state.copyWith(isBroadcastingOriginalTransaction: true)); final updatedPayjoin = @@ -920,26 +1033,22 @@ class ReceiveBloc extends Bloc { void _watchPayjoin(String payjoinId) { // Cancel the previous subscription if it exists _payjoinSubscription?.cancel(); - _payjoinSubscription = _watchPayjoinUsecase.execute(ids: [payjoinId]).listen(( - updatedPayjoin, - ) { - log.info( - '[ReceiveBloc] Watched payjoin ${updatedPayjoin.id} updated: ${updatedPayjoin.status}', - ); - add(ReceivePayjoinUpdated(updatedPayjoin)); - }); - } - - /// Subscribes once (guarded so re-entering the bitcoin receive screen - /// doesn't stack subscriptions) to the payjoin-enabled setting, so a - /// receive screen already open reacts live to the user flipping it in - /// Settings instead of requiring a re-entry. - void _watchPayjoinEnabledChanges() { - _payjoinEnabledSubscription ??= _watchPayjoinEnabledChangesUsecase - .execute() - .listen((isEnabled) { + // The receive flow only deals with the receiver side of a payjoin + // (WatchPayjoinUsecase now emits senders too, for the send flow). + _payjoinSubscription = _watchPayjoinUsecase + .execute(ids: [payjoinId]) + .where((payjoin) => payjoin is PayjoinReceiver) + .cast() + .listen((updatedPayjoin) { + // cancel() stops FUTURE events but not one already in flight on the + // microtask queue; the repository's poll/expiry timers outlive + // this bloc, so an event can arrive after close(). Never add to a + // closed bloc (it throws). if (isClosed) return; - add(ReceivePayjoinEnabledChanged(isEnabled)); + log.info( + '[ReceiveBloc] Watched payjoin ${updatedPayjoin.id} updated: ${updatedPayjoin.status}', + ); + add(ReceivePayjoinUpdated(updatedPayjoin)); }); } @@ -952,6 +1061,8 @@ class ReceiveBloc extends Bloc { _walletTransactionSubscription = _watchWalletTransactionByAddressUsecase .execute(walletId: walletId, toAddress: address) .listen((tx) { + // See _watchPayjoin's guard above for why this is needed. + if (isClosed) return; add(ReceiveTransactionReceived(tx)); }); } @@ -960,6 +1071,8 @@ class ReceiveBloc extends Bloc { // Cancel the previous subscription if it exists _swapSubscription?.cancel(); _swapSubscription = _watchSwapUsecase.execute(swapId).listen((updatedSwap) { + // See _watchPayjoin's guard above for why this is needed. + if (isClosed) return; log.info( '[ReceiveBloc] Watched swap ${updatedSwap.id} updated: ${updatedSwap.status}', ); diff --git a/lib/features/receive/presentation/bloc/receive_event.dart b/lib/features/receive/presentation/bloc/receive_event.dart index 73ced49ff6..896e5501b4 100644 --- a/lib/features/receive/presentation/bloc/receive_event.dart +++ b/lib/features/receive/presentation/bloc/receive_event.dart @@ -15,18 +15,18 @@ class ReceiveEvent with _$ReceiveEvent { const factory ReceiveEvent.receiveNoteChanged(String note) = ReceiveNoteChanged; const factory ReceiveEvent.receiveNoteSaved() = ReceiveNoteSaved; - const factory ReceiveEvent.receiveAddressOnlyToggled(bool isAddressOnly) = - ReceiveAddressOnlyToggled; const factory ReceiveEvent.receiveNewAddressGenerated() = ReceiveNewAddressGenerated; const factory ReceiveEvent.receivePayjoinUpdated(PayjoinReceiver payjoin) = ReceivePayjoinUpdated; - const factory ReceiveEvent.receivePayjoinEnabledChanged(bool isEnabled) = - ReceivePayjoinEnabledChanged; const factory ReceiveEvent.receivePayjoinOriginalTxBroadcasted() = ReceivePayjoinOriginalTxBroadcasted; const factory ReceiveEvent.receiveTransactionReceived(WalletTransaction tx) = ReceiveTransactionReceived; const factory ReceiveEvent.receiveLightningSwapUpdated(LnReceiveSwap swap) = ReceiveLightningSwapUpdated; + const factory ReceiveEvent.receivePayjoinSettingChanged(bool enabled) = + ReceivePayjoinSettingChanged; + const factory ReceiveEvent.receivePayjoinToggled(bool enabled) = + ReceivePayjoinToggled; } diff --git a/lib/features/receive/presentation/bloc/receive_state.dart b/lib/features/receive/presentation/bloc/receive_state.dart index 19cd45cebe..8f7e7a553a 100644 --- a/lib/features/receive/presentation/bloc/receive_state.dart +++ b/lib/features/receive/presentation/bloc/receive_state.dart @@ -21,10 +21,19 @@ abstract class ReceiveState with _$ReceiveState { WalletAddress? liquidAddress, @Default('') String note, PayjoinReceiver? payjoin, - bool? isPayjoinEnabled, + // The global payjoin setting as last read by the bloc. Tri-state on + // purpose: null = settings not fetched yet (treat as "may become + // enabled", keep waiting), false = disabled (never wait for a payjoin), + // true = enabled (wait until a session or an exception exists). See + // [isPayjoinLoading] for why this must be part of the state. + bool? payjoinGloballyEnabled, + // The receiver's anti-probing minimum (sats) as last read from settings. + // Null until the first settings fetch resolves. Used only to explain a + // below-minimum decline on the payjoin-in-progress screen — the decline + // itself happens in PayjoinRepositoryImpl before any negotiation. + int? payjoinMinAmountSat, @Default(false) bool isBroadcastingOriginalTransaction, ReceivePayjoinException? receivePayjoinException, - @Default(false) bool isAddressOnly, WalletTransaction? tx, Object? error, AmountException? amountException, @@ -232,40 +241,140 @@ abstract class ReceiveState with _$ReceiveState { } } + /// Whether the payjoin flow owns navigation for this receive, so the + /// shell's generic "payment received → transaction details" listener must + /// defer: the payjoin-in-progress screen lives on the root navigator and + /// stays mounted over the receive shell, so without this the generic + /// listener would whisk the user off the payjoin screen the instant the + /// address watcher sees the (possibly fallback) transaction — before they + /// can read why a payjoin did or didn't happen. `started` is deliberately + /// excluded: a fresh receiver session with no request yet means a plain + /// send to this address, unrelated to payjoin, which must still navigate + /// via the generic listener. + bool get isPayjoinFlowOwningNavigation => + type == ReceiveType.bitcoin && + payjoin != null && + payjoin!.status != PayjoinStatus.started; + + /// True when the payjoin session resolved via the plain-broadcast fallback + /// specifically because the sender's amount fell below the configured + /// anti-probing minimum. Exact, not a heuristic: the repository declines + /// below-minimum requests before any negotiation is attempted, so no other + /// abort path is reachable for such an amount. + bool get isPayjoinBelowMinimum { + final amountSat = payjoin?.amountSat; + final minAmountSat = payjoinMinAmountSat; + return payjoin?.isAborted == true && + amountSat != null && + minAmountSat != null && + amountSat < minAmountSat; + } + bool get isPayjoinLoading { if (type == ReceiveType.bitcoin) { - // isPayjoinEnabled is null until the settings fetch in - // ReceiveBloc._onBitcoinStarted completes; treat that as "still - // waiting" the same as before. Once it's explicitly false, there is - // nothing to wait for — paymentRequest must fall through to the - // plain address/BIP21 immediately instead of blocking forever. + // Gated on [payjoinGloballyEnabled]: when payjoin is disabled in + // settings, ReceiveBloc never creates a session and never sets an + // exception (see _onBitcoinStarted), so [payjoin] stays null forever + // and this getter must not keep reporting "still loading" + // indefinitely — otherwise [paymentRequest] (which waits on this so + // the QR doesn't flip from address-only to a pj= BIP21 mid-display) + // never resolves and the receive QR never renders at all. Payjoin is + // disabled by default, so without this gate that would be the state + // of every fresh install. `null` (settings not read yet) still + // counts as loading: the fetch resolves within the same handler that + // would create the session. + // + // Also gated on [hasUtxos]: ReceiveBloc only creates a session for a + // wallet with a confirmed balance to contribute (see + // ReceiveBloc._isPayjoinEligible — a payjoin proposal needs at least + // one UTXO), so an empty wallet would otherwise hit the exact same + // "stuck loading forever" bug as the disabled case. return wallet != null && wallet!.signsLocally && - !isAddressOnly && - isPayjoinEnabled != false && + (payjoinGloballyEnabled ?? true) && + hasUtxos && payjoin == null && receivePayjoinException == null; } return false; } + /// True when payjoin is enabled globally and this wallet can sign + /// locally, but it has no confirmed balance yet — so ReceiveBloc did not + /// create a payjoin receiver session (see ReceiveBloc._isPayjoinEligible). + /// Lets the receive screen explain why payjoin isn't active despite being + /// turned on, instead of silently doing nothing. + bool get isPayjoinAwaitingFunds { + if (type != ReceiveType.bitcoin) return false; + return wallet != null && + wallet!.signsLocally && + payjoinGloballyEnabled == true && + !hasUtxos && + payjoin == null; + } + bool get isPayjoinAvailable { if (type == ReceiveType.bitcoin) { - return wallet != null && - wallet!.signsLocally && - !isAddressOnly && - payjoin != null; + return wallet != null && wallet!.signsLocally && payjoin != null; } return false; } - bool get hasUtxos => (wallet?.balanceSat ?? BigInt.zero) > BigInt.zero; + // Gated on the CONFIRMED component of the balance, not the total: a payjoin + // proposal needs a real, already-confirmed UTXO to contribute as an input + // (an unconfirmed one is not filtered out anywhere downstream and could be + // replaced/invalidated). Fail-closed on a wallet whose confirmedBalanceSat + // hasn't been populated (null) rather than falling back to balanceSat, + // which would silently reintroduce the total-vs-confirmed gap this exists + // to close. + bool get hasUtxos => + (wallet?.confirmedBalanceSat ?? BigInt.zero) > BigInt.zero; + + /// Whether a payjoin on/off toggle should be offered on the receive screen + /// for this wallet: it must be a bitcoin receive with a locally-signing, + /// funded wallet (the only case where flipping the setting actually changes + /// anything — a watch-only or empty wallet can never payjoin regardless). + /// The toggle reflects/controls the GLOBAL [payjoinGloballyEnabled] setting. + bool get isPayjoinToggleable => + type == ReceiveType.bitcoin && + wallet != null && + wallet!.signsLocally && + hasUtxos; + + /// True when the user has entered a requested amount that is below the + /// configured anti-probing minimum. The receiver would decline a payjoin + /// for such an amount anyway (see PayjoinRepositoryImpl's below-minimum + /// decline), so advertising a pj= endpoint for it is pointless — this lets + /// [canPayjoin] drop the endpoint from the QR for this request without + /// tearing down the underlying session (a larger amount, or clearing the + /// amount, re-enables it immediately). Only gates once an amount is + /// actually entered (> 0); a plain address / no-amount request is + /// unaffected. + bool get isRequestedAmountBelowPayjoinMinimum { + final amountSat = confirmedAmountSat; + final minAmountSat = payjoinMinAmountSat; + return amountSat != null && + amountSat > 0 && + minAmountSat != null && + amountSat < minAmountSat; + } // Payjoin is only useful if the wallet has UTXOs to contribute as inputs in // the receiver's BIP78 PSBT — without UTXOs the proposal cannot be built. - // Also gated on [isAddressOnly] so toggling payjoin OFF removes only the - // pj= param from the URI, leaving amount/message intact. - bool get canPayjoin => payjoin != null && hasUtxos && !isAddressOnly; + // The per-address opt-out toggle was removed: the global payjoin setting + // (ReceiveBloc only creates [payjoin] at all when it's enabled, see + // _onBitcoinStarted) is now the only control. Also suppressed when the + // requested amount is below the anti-probing minimum: the sender's payjoin + // would be declined for it anyway, so the QR shouldn't advertise pj=. + bool get canPayjoin => + payjoin != null && hasUtxos && !isRequestedAmountBelowPayjoinMinimum; + + /// A payjoin session exists (feature on, funded wallet) but the pj= + /// endpoint is currently dropped from the QR solely because the requested + /// amount is below the anti-probing minimum. Lets the receive screen + /// explain the transient suppression rather than silently omitting pj=. + bool get isPayjoinSuppressedByAmount => + payjoin != null && hasUtxos && isRequestedAmountBelowPayjoinMinimum; double get payjoinAmountFiat { final payjoinAmountSat = payjoin?.amountSat ?? 0; diff --git a/lib/features/receive/receive_locator.dart b/lib/features/receive/receive_locator.dart index 7d9c261ce8..5bdedbde46 100644 --- a/lib/features/receive/receive_locator.dart +++ b/lib/features/receive/receive_locator.dart @@ -17,6 +17,7 @@ import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_by_address_usecase.dart'; import 'package:bb_mobile/features/labels/labels_facade.dart'; import 'package:bb_mobile/features/receive/domain/usecases/create_receive_swap_use_case.dart'; +import 'package:bb_mobile/features/settings/domain/usecases/set_payjoin_enabled_usecase.dart'; import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; import 'package:get_it/get_it.dart'; @@ -49,13 +50,14 @@ class ReceiveLocator { broadcastOriginalTransactionUsecase: locator(), watchPayjoinUsecase: locator(), - watchPayjoinEnabledChangesUsecase: - locator(), watchWalletTransactionByAddressUsecase: locator(), watchSwapUsecase: locator(), labelsFacade: locator(), getSwapLimitsUsecase: locator(), + watchPayjoinEnabledChangesUsecase: + locator(), + setPayjoinEnabledUsecase: locator(), wallet: wallet, ), ); diff --git a/lib/features/receive/ui/receive_router.dart b/lib/features/receive/ui/receive_router.dart index 7f7f05bfa3..56edc0e3ae 100644 --- a/lib/features/receive/ui/receive_router.dart +++ b/lib/features/receive/ui/receive_router.dart @@ -1,4 +1,3 @@ -import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; import 'package:bb_mobile/features/receive/ui/screens/receive_amount_screen.dart'; @@ -42,20 +41,32 @@ class ReceiveRouter { // of the incoming route final wallet = state.extra is Wallet ? state.extra! as Wallet : null; - // Make sure the ReceiveScaffold with the network selection is not rebuild - // when switching networks, so keep it outside of the BlocProvider. - return ReceiveScaffold( - wallet: wallet, - child: BlocProvider( - create: (_) => locator(param1: wallet), + // The BlocProvider wraps the whole ReceiveScaffold (not just its child) + // so the scaffold's TopBar can host a bloc-aware payjoin toggle. The + // scaffold itself doesn't listen — it only rebuilds via the narrowly + // scoped selectors inside its TopBar action — so switching networks + // still doesn't rebuild it. + return BlocProvider( + create: (_) => locator(param1: wallet), + child: ReceiveScaffold( + wallet: wallet, child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (previous, current) => // makes sure it doesn't go from payment received to payment in progress again previous.isPaymentReceived != true && - previous.isPaymentInProgress != true && - current.isPaymentInProgress == true, + ((previous.isPaymentInProgress != true && + current.isPaymentInProgress == true) || + // A payjoin session can be first observed past + // `requested` (proposed, or already terminal) if the + // stream subscription raced a fast sender or a resume: + // any post-`started` status means the payjoin flow owns + // the UX from here (see isPayjoinFlowOwningNavigation) — + // navigate into the payjoin screen, which renders the + // right in-progress or terminal flavor. + (previous.isPayjoinFlowOwningNavigation != true && + current.isPayjoinFlowOwningNavigation == true)), listener: (context, state) { final bloc = context.read(); final type = state.type; @@ -66,14 +77,14 @@ class ReceiveRouter { // it uses the root navigator and so doesn't have the ReceiveBloc // in the context. We need to pass it as an extra parameter. if (type == ReceiveType.bitcoin && - state.payjoin?.status == PayjoinStatus.requested) { + state.isPayjoinFlowOwningNavigation) { context.goNamed( ReceiveRoute.payjoinInProgress.name, extra: bloc, ); } else if (type == ReceiveType.lightning) { context.goNamed( - ReceiveRoute.lightningPaymentInProgress.path, + ReceiveRoute.lightningPaymentInProgress.name, extra: bloc, ); } @@ -82,7 +93,12 @@ class ReceiveRouter { BlocListener( listenWhen: (previous, current) => previous.isPaymentReceived != true && - current.isPaymentReceived == true, + current.isPaymentReceived == true && + // The payjoin-in-progress screen (on the root navigator) + // owns navigation once a payjoin session exists past + // `started`; don't let this generic listener whisk the + // user away before they can read the payjoin outcome. + !current.isPayjoinFlowOwningNavigation, listener: (context, state) { final bloc = context.read(); final type = state.type; diff --git a/lib/features/receive/ui/screens/receive_payjoin_in_progress_screen.dart b/lib/features/receive/ui/screens/receive_payjoin_in_progress_screen.dart index 71f4b949d1..e17bb86370 100644 --- a/lib/features/receive/ui/screens/receive_payjoin_in_progress_screen.dart +++ b/lib/features/receive/ui/screens/receive_payjoin_in_progress_screen.dart @@ -4,12 +4,16 @@ import 'package:bb_mobile/core/payjoin/domain/usecases/broadcast_original_transa import 'package:bb_mobile/core/themes/app_theme.dart'; import 'package:bb_mobile/core/utils/amount_formatting.dart'; import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bb_mobile/core/utils/constants.dart' show PayjoinConstants; import 'package:bb_mobile/core/utils/logger.dart'; import 'package:bb_mobile/core/widgets/buttons/button.dart'; import 'package:bb_mobile/core/widgets/loading/fading_linear_progress.dart'; import 'package:bb_mobile/core/widgets/navbar/top_bar.dart'; +import 'package:bb_mobile/core/widgets/text/text.dart'; +import 'package:bb_mobile/core/widgets/timers/countdown.dart'; import 'package:bb_mobile/features/bitcoin_price/ui/currency_text.dart'; import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; +import 'package:bb_mobile/features/transactions/ui/transactions_router.dart'; import 'package:bb_mobile/features/wallet/ui/wallet_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,33 +28,62 @@ class ReceivePayjoinInProgressScreen extends StatelessWidget { final isBroadcasting = context.select( (ReceiveBloc bloc) => bloc.state.isBroadcastingOriginalTransaction, ); - // TODO: PopScope can be removed since we can do pop here now - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; // Don't allow back navigation - - context.go(WalletRoute.walletHome.path); + // This screen lives on the root navigator, so the receive ShellRoute's + // navigation BlocListeners are unmounted while it is shown — it has to + // move itself out once the session reaches its happy terminal state. + // Without this the user stayed on "payjoin in progress" indefinitely + // after the payjoin completed, with the top-bar close button as the only + // way out. + // + // Gated on a REAL payjoin completion (isCompleted == status completed + // only, in this codebase), NOT the aborted fallback: a session that + // completed via the plain-broadcast fallback (declined below the + // anti-probing minimum, a failed negotiation, or an expiry with no + // proposal ever exchanged) is deliberately NOT auto-navigated away from — + // PayjoinInProgressPage instead settles on an explanatory message (why + // there was no payjoin) that the user would otherwise never get to read + // if this immediately jumped to transaction details. Same reasoning for + // `expired`: it still offers the manual "receive payment normally" + // fallback below. + return BlocListener( + listenWhen: (previous, current) => + previous.payjoin?.isCompleted != true && + current.payjoin?.isCompleted == true, + listener: (context, state) { + context.goNamed( + TransactionsRoute.payjoinTransactionDetails.name, + pathParameters: {'payjoinId': state.payjoin!.id}, + queryParameters: {'returnHome': 'true'}, + ); }, - child: Scaffold( - appBar: AppBar( - forceMaterialTransparency: true, - automaticallyImplyLeading: false, - flexibleSpace: TopBar( - title: context.loc.receiveTitle, - actionIcon: Icons.close, - onAction: () => context.go(WalletRoute.walletHome.path), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(3.0), - child: FadingLinearProgress( - trigger: isBroadcasting, - backgroundColor: context.appColors.onPrimary, - foregroundColor: context.appColors.primary, + // TODO: PopScope can be removed since we can do pop here now + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; // Don't allow back navigation + + context.go(WalletRoute.walletHome.path); + }, + child: Scaffold( + appBar: AppBar( + forceMaterialTransparency: true, + automaticallyImplyLeading: false, + flexibleSpace: TopBar( + title: context.loc.receiveTitle, + actionIcon: Icons.close, + onAction: () => context.go(WalletRoute.walletHome.path), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(3.0), + child: FadingLinearProgress( + trigger: isBroadcasting, + backgroundColor: context.appColors.onPrimary, + foregroundColor: context.appColors.primary, + ), ), ), + body: const PayjoinInProgressPage(), ), - body: const PayjoinInProgressPage(), ), ); } @@ -70,53 +103,219 @@ class PayjoinInProgressPage extends StatelessWidget { final fiatCurrencyCode = context.select( (ReceiveBloc bloc) => bloc.state.fiatCurrencyCode, ); - final isBroadcasted = context.select( + final payjoinId = context.select( + (ReceiveBloc bloc) => bloc.state.payjoin?.id, + ); + // A real payjoin: the counterparty actually completed the negotiation + // and its own transaction was broadcast. This screen auto-navigates + // away as soon as this becomes true (see the BlocListener above), so in + // practice this branch is only ever on screen for a brief instant. + final isRealPayjoin = context.select( + (ReceiveBloc bloc) => bloc.state.payjoin?.isCompleted == true, + ); + // Completed, but NOT via a real payjoin: the plain-broadcast fallback + // paid the sender instead — declined below the anti-probing minimum, a + // failed negotiation, or an expiry with no proposal ever exchanged (see + // PayjoinStatus.aborted). Unlike isRealPayjoin, this state is NOT + // auto-navigated away from: the user explicitly expected a payjoin, so + // they get to read why one didn't happen instead of landing on + // transaction details unannounced. + final isFallbackCompleted = context.select( + (ReceiveBloc bloc) => bloc.state.payjoin?.isAborted == true, + ); + // The specific, most informative case: the request was declined solely + // because its amount fell under the configured anti-probing threshold. + // Exact, not a heuristic — see ReceiveState.isPayjoinBelowMinimum. + final isBelowMinimum = context.select( + (ReceiveBloc bloc) => bloc.state.isPayjoinBelowMinimum, + ); + // Distinct from a completed session: the session's own window closed + // WITHOUT the counterparty completing it. The automatic plain-broadcast + // fallback (PayjoinRepositoryImpl._processExpiredPayjoin) usually + // resolves this into isFallbackCompleted=true within a second or two, + // but if that fallback itself fails (no network at that exact moment), + // status stays `expired` indefinitely — without this branch the screen + // kept showing the same "in progress, wait" copy forever, giving the + // user no signal that waiting longer would not help and the manual + // "receive normally" button below was their only way out. + final isExpired = context.select( + (ReceiveBloc bloc) => bloc.state.payjoin?.status == PayjoinStatus.expired, + ); + // The single source of truth ReceiveBloc's own action guard + // (_onPayjoinOriginalTxBroadcasted) agrees with — see + // Payjoin.canManuallyBroadcastOriginal's doc comment. Deriving the + // button's visibility from the exact same getter as the action means + // they can never disagree. + final canManuallyBroadcastOriginal = context.select( (ReceiveBloc bloc) => - bloc.state.payjoin?.status == PayjoinStatus.completed, + bloc.state.payjoin?.canManuallyBroadcastOriginal ?? false, + ); + final payjoinExpiresAt = context.select( + (ReceiveBloc bloc) => bloc.state.payjoin?.expiresAt, ); + // The default session expiry is 24h; the Countdown widget renders + // minutes:seconds (a day would show a meaningless "1440:00"), so only + // reveal it when the fallback is genuinely imminent (≤ 1h remaining). + // Computed once per build — acceptable for a display-only hint, the + // screen rebuilds on each cubit emit (once per poll at most). + final isFallbackImminent = + payjoinExpiresAt != null && + payjoinExpiresAt.difference(DateTime.now()) <= const Duration(hours: 1); - return Center( + // Mirrors SendSucessScreen's layout so both terminal payment screens read + // the same: centered copy with horizontal margins, the amount block, and + // a single bottom-anchored action button. + return Padding( + padding: const EdgeInsets.all(16), child: Column( + crossAxisAlignment: .stretch, mainAxisAlignment: .center, children: [ - if (isBroadcasted) ...[ - Text( - context.loc.receivePaymentInProgress, - style: context.font.headlineLarge, + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + if (isBelowMinimum) ...[ + BBText( + context.loc.receivePayjoinBelowMinimum, + style: context.font.headlineLarge, + maxLines: 2, + textAlign: .center, + ), + const Gap(8), + BBText( + context.loc.receivePayjoinBelowMinimumSubtext, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + ] else if (isFallbackCompleted) ...[ + BBText( + context.loc.receivePayjoinFallbackCompleted, + style: context.font.headlineLarge, + maxLines: 2, + textAlign: .center, + ), + const Gap(8), + BBText( + context.loc.receivePayjoinFallbackCompletedSubtext, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + ] else if (isRealPayjoin) ...[ + BBText( + context.loc.receivePaymentInProgress, + style: context.font.headlineLarge, + maxLines: 2, + textAlign: .center, + ), + const Gap(8), + BBText( + context.loc.receiveBitcoinConfirmationMessage, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + ] else if (isExpired) ...[ + BBText( + context.loc.receivePayjoinExpired, + style: context.font.headlineLarge, + maxLines: 2, + textAlign: .center, + ), + const Gap(8), + BBText( + context.loc.receivePayjoinExpiredSubtext, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + ] else ...[ + BBText( + context.loc.receivePayjoinInProgress, + style: context.font.headlineLarge, + maxLines: 2, + textAlign: .center, + ), + const Gap(8), + BBText( + context.loc.receiveWaitForPayjoin, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + // Gated on the same canManuallyBroadcastOriginal as the + // fallback button below — a single source of truth so the + // countdown never outlives the action it is counting down + // to (see Payjoin.canManuallyBroadcastOriginal). + if (canManuallyBroadcastOriginal && + payjoinExpiresAt != null && + isFallbackImminent) ...[ + const Gap(8), + Row( + mainAxisAlignment: .center, + children: [ + BBText( + context.loc.receivePayjoinFallbackCountdown, + style: context.font.bodyMedium, + color: context.appColors.secondary, + ), + const Gap(4), + Countdown( + until: payjoinExpiresAt.add( + const Duration( + seconds: + PayjoinConstants.directoryPollingInterval, + ), + ), + onTimeout: () {}, + ), + ], + ), + ], + ], + if (amountSat != null) ...[ + const Gap(16), + CurrencyText( + amountSat, + showFiat: false, + style: context.font.displaySmall, + textAlign: .center, + ), + const Gap(4), + BBText( + '~${FormatAmount.fiat(amountFiat, fiatCurrencyCode)}', + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: .center, + ), + ], + ], ), - Text( - context.loc.receiveBitcoinConfirmationMessage, - style: context.font.headlineMedium, - ), - ] else ...[ - Text( - context.loc.receivePayjoinInProgress, - style: context.font.headlineLarge, - ), - Text( - context.loc.receiveWaitForPayjoin, - style: context.font.bodyMedium, - ), - ], - if (amountSat != null) ...[ - const Gap(16), - CurrencyText( - amountSat, - showFiat: false, - style: context.font.headlineLarge, - ), - const Gap(4), - Text( - '~${FormatAmount.fiat(amountFiat, fiatCurrencyCode)}', - style: context.font.bodyLarge?.copyWith( - color: context.appColors.onSurfaceVariant, + ), + const Spacer(flex: 2), + if (isFallbackCompleted && payjoinId != null) + BBButton.big( + label: context.loc.receiveViewDetails, + onPressed: () => context.goNamed( + TransactionsRoute.payjoinTransactionDetails.name, + pathParameters: {'payjoinId': payjoinId}, + queryParameters: {'returnHome': 'true'}, ), - ), - ], - if (!isBroadcasted) ...[ - const Gap(84), + bgColor: context.appColors.secondary, + textColor: context.appColors.onSecondary, + ) + else if (canManuallyBroadcastOriginal) const ReceiveBroadcastPayjoinButton(), - ], + const Gap(32), ], ), ); diff --git a/lib/features/receive/ui/screens/receive_qr_screen.dart b/lib/features/receive/ui/screens/receive_qr_screen.dart index 6dc41e6f58..6b1b266d80 100644 --- a/lib/features/receive/ui/screens/receive_qr_screen.dart +++ b/lib/features/receive/ui/screens/receive_qr_screen.dart @@ -4,6 +4,7 @@ import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet_address.dart'; import 'package:bb_mobile/core/widgets/buttons/button.dart'; +import 'package:bb_mobile/core/widgets/cards/info_card.dart'; import 'package:bb_mobile/core/widgets/address_viewer.dart'; import 'package:bb_mobile/core/widgets/bottom_sheet/disclosure_bottom_sheet.dart'; import 'package:bb_mobile/core/widgets/invoice_viewer.dart'; @@ -92,6 +93,12 @@ class ReceiveQRDetails extends StatelessWidget { ); final selectedWallet = context.watch().state.wallet; final wallets = context.select((ReceiveBloc bloc) => bloc.state.wallets); + final isPayjoinAwaitingFunds = context.select( + (ReceiveBloc bloc) => bloc.state.isPayjoinAwaitingFunds, + ); + final isPayjoinSuppressedByAmount = context.select( + (ReceiveBloc bloc) => bloc.state.isPayjoinSuppressedByAmount, + ); final gap = Device.screen.height * 0.02; return Padding( @@ -140,8 +147,23 @@ class ReceiveQRDetails extends StatelessWidget { ), Gap(gap), Center(child: QrDisplayWidget(data: qrData)), - const _PayjoinSwitch(), Gap(gap), + if (isBitcoin && isPayjoinAwaitingFunds) ...[ + InfoCard( + description: context.loc.receivePayjoinAwaitingFunds, + tagColor: context.appColors.secondary, + bgColor: context.appColors.onSecondary, + ), + Gap(gap), + ], + if (isBitcoin && isPayjoinSuppressedByAmount) ...[ + InfoCard( + description: context.loc.receivePayjoinBelowMinimumAmount, + tagColor: context.appColors.secondary, + bgColor: context.appColors.onSecondary, + ), + Gap(gap), + ], BorderedTappableTile( backgroundColor: context.appColors.surfaceContainerHighest, onTap: () => isLightning @@ -631,74 +653,6 @@ class _ReceiveLnFeesDetailsState extends State { } } -class _PayjoinSwitch extends StatelessWidget { - const _PayjoinSwitch(); - - @override - Widget build(BuildContext context) { - final canUsePayjoin = context.select( - (bloc) => - bloc.state.type == ReceiveType.bitcoin && - (bloc.state.wallet?.signsLocally ?? false), - ); - if (!canUsePayjoin) return const SizedBox.shrink(); - - final hasUtxos = context.select( - (bloc) => bloc.state.hasUtxos, - ); - final isAddressOnly = context.select( - (bloc) => bloc.state.isAddressOnly, - ); - final isOn = !isAddressOnly && hasUtxos; - - void toggle() { - final turnOn = !isOn; - if (turnOn && !hasUtxos) { - SnackBarUtils.showSnackBar(context, context.loc.receivePayjoinNoUtxos); - return; - } - context.read().add( - ReceiveEvent.receiveAddressOnlyToggled(!turnOn), - ); - } - - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: toggle, - borderRadius: BorderRadius.circular(8), - child: Ink( - decoration: BoxDecoration( - color: context.appColors.onSecondary, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.appColors.secondaryFixedDim), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 4), - child: Row( - children: [ - Expanded( - child: BBText( - context.loc.receivePayjoinActivated, - style: context.font.bodyLarge, - color: context.appColors.secondary, - ), - ), - AbsorbPointer( - child: Switch(value: isOn, onChanged: (_) {}), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - class ReceiveNewAddressButton extends StatelessWidget { const ReceiveNewAddressButton({super.key}); diff --git a/lib/features/receive/ui/screens/receive_scaffold.dart b/lib/features/receive/ui/screens/receive_scaffold.dart index f14f99fcef..c7e4fad797 100644 --- a/lib/features/receive/ui/screens/receive_scaffold.dart +++ b/lib/features/receive/ui/screens/receive_scaffold.dart @@ -1,9 +1,12 @@ import 'package:bb_mobile/core/utils/build_context_x.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/core/widgets/navbar/top_bar.dart'; +import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; import 'package:bb_mobile/features/receive/ui/widgets/receive_network_selection.dart'; +import 'package:bb_mobile/features/receive/ui/widgets/receive_payjoin_toggle_button.dart'; import 'package:bb_mobile/features/wallet/ui/wallet_router.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -15,6 +18,13 @@ class ReceiveScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + // Only reserve the TopBar trailing slot for the payjoin toggle when it + // will actually render — Bitcoin receive with a payjoin-capable wallet — + // so the title stays centred on Liquid/Lightning and non-eligible + // wallets. Narrow selector: the scaffold only rebuilds on this bool. + final showPayjoinToggle = context.select( + (ReceiveBloc bloc) => bloc.state.isPayjoinToggleable, + ); return GestureDetector( onTap: () { FocusScope.of(context).unfocus(); @@ -34,6 +44,12 @@ class ReceiveScaffold extends StatelessWidget { context.goNamed(WalletRoute.walletHome.name); } }, + // Payjoin on/off toggle — only for Bitcoin receives with a + // payjoin-capable wallet; null (no trailing) otherwise so the + // title stays centred. + action: showPayjoinToggle + ? const ReceivePayjoinToggleButton() + : null, ), ), body: Column( diff --git a/lib/features/receive/ui/widgets/receive_payjoin_toggle_button.dart b/lib/features/receive/ui/widgets/receive_payjoin_toggle_button.dart new file mode 100644 index 0000000000..4879dd1684 --- /dev/null +++ b/lib/features/receive/ui/widgets/receive_payjoin_toggle_button.dart @@ -0,0 +1,75 @@ +import 'package:bb_mobile/core/themes/app_theme.dart'; +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bb_mobile/core/widgets/text/text.dart'; +import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; +import 'package:bb_mobile/features/settings/ui/settings_router.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; + +/// Payjoin on/off toggle chip for the receive TopBar. Green (success) = +/// enabled, red (error) = disabled; tapping flips the GLOBAL payjoin setting; +/// long-pressing opens the payjoin settings screen (min amount, expiry). +/// +/// Renders nothing unless this is a Bitcoin receive with a payjoin-capable +/// wallet ([ReceiveState.isPayjoinToggleable] — funded + locally-signing), so +/// it never shows on Liquid/Lightning receives or for wallets that could +/// never payjoin. +class ReceivePayjoinToggleButton extends StatelessWidget { + const ReceivePayjoinToggleButton({super.key}); + + @override + Widget build(BuildContext context) { + final isToggleable = context.select( + (ReceiveBloc bloc) => bloc.state.isPayjoinToggleable, + ); + if (!isToggleable) return const SizedBox.shrink(); + + final enabled = context.select( + (ReceiveBloc bloc) => bloc.state.payjoinGloballyEnabled ?? false, + ); + + final bgColor = enabled + ? context.appColors.success + : context.appColors.error; + final fgColor = enabled + ? context.appColors.onSuccess + : context.appColors.onError; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.read().add( + ReceiveEvent.receivePayjoinToggled(!enabled), + ), + onLongPress: () => + context.pushNamed(SettingsRoute.payjoinSettings.name), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + enabled ? Icons.check_circle : Icons.cancel, + size: 18, + color: fgColor, + ), + const Gap(6), + BBText( + context.loc.receivePayjoinQrBadge, + style: context.font.bodyMedium, + color: fgColor, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/send/presentation/bloc/send_cubit.dart b/lib/features/send/presentation/bloc/send_cubit.dart index 758107c6ad..c80c39ce85 100644 --- a/lib/features/send/presentation/bloc/send_cubit.dart +++ b/lib/features/send/presentation/bloc/send_cubit.dart @@ -9,7 +9,9 @@ import 'package:bb_mobile/core/exchange/domain/usecases/get_available_currencies import 'package:bb_mobile/core/fees/domain/fee_preview_cache.dart'; import 'package:bb_mobile/core/fees/domain/fees_entity.dart'; import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; import 'package:bb_mobile/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dart'; import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/consolidation_required_exception.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/check_liquid_consolidation_usecase.dart'; @@ -69,6 +71,7 @@ class SendCubit extends Cubit required this._prepareBitcoinSendUsecase, required this._prepareLiquidSendUsecase, required this._sendWithPayjoinUsecase, + required this._watchPayjoinUsecase, required this._getWalletsUsecase, required this._getWalletUsecase, required this._createSendSwapUsecase, @@ -120,6 +123,7 @@ class SendCubit extends Cubit final BroadcastLiquidTransactionUsecase _broadcastLiquidTxUsecase; final BroadcastBitcoinTransactionUsecase _broadcastBitcoinTxUsecase; final SendWithPayjoinUsecase _sendWithPayjoinUsecase; + final WatchPayjoinUsecase _watchPayjoinUsecase; final UpdatePaidSendSwapUsecase _updatePaidSendSwapUsecase; final GetSwapLimitsUsecase _getSwapLimitsUsecase; final DecodeInvoiceUsecase _decodeInvoiceUsecase; @@ -143,6 +147,7 @@ class SendCubit extends Cubit StreamSubscription? _swapSubscription; StreamSubscription? _selectedWalletSyncingSubscription; StreamSubscription? _txSubscription; + StreamSubscription? _payjoinSubscription; /// Monotonic token bumped by [clearBitcoinFeePreviews]. A preview build /// captures it before its `await` and re-checks before writing results @@ -158,6 +163,7 @@ class SendCubit extends Cubit _swapSubscription?.cancel() ?? Future.value(), _selectedWalletSyncingSubscription?.cancel() ?? Future.value(), _txSubscription?.cancel() ?? Future.value(), + _payjoinSubscription?.cancel() ?? Future.value(), ).wait; return super.close(); } @@ -853,6 +859,7 @@ class SendCubit extends Cubit exchangeRate: exchangeRate, bitcoinUnit: bitcoinUnit, inputAmountCurrencyCode: bitcoinUnit.code, + payjoinGloballyEnabled: settings.isPayjoinEnabled, ), ); } @@ -946,6 +953,7 @@ class SendCubit extends Cubit Future onCurrencyChanged(String currencyCode) async { double exchangeRate = state.exchangeRate; String fiatCurrencyCode = state.fiatCurrencyCode; + bool payjoinGloballyEnabled = state.payjoinGloballyEnabled; if (![BitcoinUnit.btc.code, BitcoinUnit.sats.code].contains(currencyCode)) { // If the currency is a fiat currency, retrieve the exchange rate and replace @@ -962,8 +970,10 @@ class SendCubit extends Cubit _convertSatsToCurrencyAmountUsecase.execute(), ]); - fiatCurrencyCode = (currencyValues[0] as SettingsEntity).currencyCode; + final settings = currencyValues[0] as SettingsEntity; + fiatCurrencyCode = settings.currencyCode; exchangeRate = currencyValues[1] as double; + payjoinGloballyEnabled = settings.isPayjoinEnabled; } emit( @@ -971,6 +981,7 @@ class SendCubit extends Cubit inputAmountCurrencyCode: currencyCode, fiatCurrencyCode: fiatCurrencyCode, exchangeRate: exchangeRate, + payjoinGloballyEnabled: payjoinGloballyEnabled, amount: '', // Clear the amount when changing the currency ), ); @@ -1898,11 +1909,8 @@ class SendCubit extends Cubit state.copyWith(signedLiquidTx: signedPset, signingTransaction: false), ); } else { - final paymentRequest = state.paymentRequest; - if (state.isToSelf != true && - paymentRequest != null && - paymentRequest is Bip21PaymentRequest && - paymentRequest.pj.isNotEmpty) { + if (state.willAttemptPayjoin) { + final paymentRequest = state.paymentRequest! as Bip21PaymentRequest; final payjoinSender = await _sendWithPayjoinUsecase.execute( walletId: state.selectedWallet!.id, isTestnet: state.selectedWallet!.network.isTestnet, @@ -1912,18 +1920,20 @@ class SendCubit extends Cubit networkFeesSatPerVb: state.selectedFee!.isRelative ? state.selectedFee!.value as double : 1, - expireAfterSec: PayjoinConstants.defaultExpireAfterSec, ); - // TODO: Watch the payjoin and transaction to update the txId with the - // payjoin txId if it is completed. - final txId = payjoinSender.originalTxId; + // Show originalTxId provisionally; the payjoin runs asynchronously + // in the repository (poll → sign → broadcast, or fallback to the + // original on expiry). Watch its stream so the send flow resolves + // to success with the final txid instead of hanging on the + // "coordinating" screen (#2246). emit( state.copyWith( - txId: txId, + txId: payjoinSender.originalTxId, payjoinSender: payjoinSender, signingTransaction: false, ), ); + _watchPayjoin(payjoinSender.id); } else { final signedPsbtAndTxSize = await _signBitcoinTxUsecase.execute( psbt: state.unsignedPsbt!, @@ -1983,19 +1993,15 @@ class SendCubit extends Cubit ); emit(state.copyWith(txId: txId)); } else { - final paymentRequest = state.paymentRequest; - if (state.isToSelf != true && - paymentRequest != null && - paymentRequest is Bip21PaymentRequest && - paymentRequest.pj.isNotEmpty) { - emit(state.copyWith(broadcastingTransaction: false)); - } else { - final txId = await _broadcastBitcoinTxUsecase.execute( - isPsbt ? state.signedBitcoinPsbt! : state.signedBitcoinTx!, - isPsbt: isPsbt, - ); - emit(state.copyWith(txId: txId)); - } + // Payjoin sends are already broadcast asynchronously by the repository + // (and their state.txId is set in signTransaction), so they never + // reach here — the guard at the top of this method returns first. Only + // plain bitcoin sends broadcast at this point. + final txId = await _broadcastBitcoinTxUsecase.execute( + isPsbt ? state.signedBitcoinPsbt! : state.signedBitcoinTx!, + isPsbt: isPsbt, + ); + emit(state.copyWith(txId: txId)); } if (state.lightningSwap != null) { @@ -2078,7 +2084,16 @@ class SendCubit extends Cubit await signTransaction(); // if (!state.isLightning) { if (state.confirmTransactionException == null) { - emit(state.copyWith(step: SendStep.sending)); + // _watchPayjoin (armed inside signTransaction's payjoin branch) + // can resolve the flow to success before this line, if a + // terminal payjoin event arrives in the gap between arming and + // here. Don't clobber an already-resolved success with + // "sending" — that would strand the flow on the sending screen + // despite having actually completed (the exact symptom #2246 + // fixes, just a narrower window of it). + if (state.step != SendStep.success) { + emit(state.copyWith(step: SendStep.sending)); + } } else { emit(state.copyWith(step: SendStep.confirm)); return; @@ -2090,11 +2105,17 @@ class SendCubit extends Cubit emit(state.copyWith(step: SendStep.confirm)); return; } - // Start watching the transaction to have the latest status - _watchWalletTransactionByTxId( - walletId: state.selectedWallet!.id, - txId: state.txId!, - ); + // For a payjoin, _watchPayjoin (started in signTransaction) owns + // resolving the flow to success — it watches the payjoin session and + // sets the final txid. Starting the tx watcher here too would race it + // (both emit) and briefly surface the original txid. For all other + // sends, watch the broadcast tx for its latest status. + if (state.payjoinSender == null) { + _watchWalletTransactionByTxId( + walletId: state.selectedWallet!.id, + txId: state.txId!, + ); + } } catch (e) { emit(state.copyWith(step: SendStep.confirm)); log.severe(error: e, trace: StackTrace.current); @@ -2179,6 +2200,113 @@ class SendCubit extends Cubit }); } + /// Watches the sender side of an in-flight payjoin and resolves the send + /// flow once it terminates. The payjoin negotiation runs asynchronously in + /// the repository; without this the UI would sit on the "coordinating" + /// screen forever (#2246). + /// + /// - completed: the receiver responded and the payjoin transaction was + /// broadcast — move to success with the payjoin txid. + /// - aborted: the repository fell back to broadcasting the original + /// transaction (below-minimum decline, failed negotiation, or the + /// counterparty's own fallback observed on-chain) — move to success + /// with the original txid. + /// - expired: terminal with nothing broadcast — the original-transaction + /// fallback itself also failed — return to confirm with a + /// broadcast-failure exception so the user can retry. + void _watchPayjoin(String payjoinId) { + _payjoinSubscription?.cancel(); + // Captured up front: the completion event fires arbitrarily later on a + // background poll, so read these off state now rather than closing over + // state (which may have moved on) inside the async callback. + final walletId = state.selectedWallet?.id; + final userLabel = state.label; + _payjoinSubscription = _watchPayjoinUsecase + .execute(ids: [payjoinId]) + .where((payjoin) => payjoin is PayjoinSender) + .cast() + .listen((payjoin) { + // The payjoin poll lives in the repository and outlives this cubit; + // an event can arrive after the send flow is torn down. Never emit + // on a closed cubit (it throws). + if (isClosed) return; + // logRef, never id: a sender payjoin id is the full BIP21 URI + // (address + amount), which must not reach logs. + log.info( + '[SendCubit] Watched payjoin ${payjoin.logRef} updated: ' + '${payjoin.status}', + ); + if (payjoin.isCompleted || payjoin.isAborted) { + emit( + state.copyWith( + payjoinSender: payjoin, + // Prefer the payjoin txid; fall back to the original tx that + // was broadcast when the negotiation didn't complete. + txId: payjoin.txId ?? payjoin.originalTxId, + step: SendStep.success, + ), + ); + _payjoinSubscription?.cancel(); + if (walletId != null) { + unawaited( + _getWalletUsecase.execute(walletId, sync: true).catchError((e) { + log.warning('Failed to sync wallet after payjoin: $e'); + return null; + }), + ); + } + // broadcastTransaction never reaches its own label-store call for + // a payjoin (it early-returns because txId is already set), so + // the user's typed label has to be stored here instead, once the + // final txid is known. + // originalTxId is always set for a sender, so this is never null. + final finalTxId = payjoin.txId ?? payjoin.originalTxId; + if (userLabel.isNotEmpty && walletId != null) { + unawaited( + _labelsFacade.store( + NewLabel.tx( + transactionId: finalTxId, + label: userLabel, + origin: walletId, + ), + ), + ); + } + } else if (payjoin.isExpired) { + // Terminal without a broadcast: either the session expired and the + // original-transaction fallback failed too, or a received proposal + // failed to sign/broadcast and the original fallback also failed + // (the repository only emits the raw expired-marked entity on one + // of these unrecoverable paths). Nothing hit the chain, so surface + // a broadcast failure and return to confirm so the user can retry, + // instead of hanging on "coordinating". + log.warning( + '[SendCubit] Payjoin ${payjoin.logRef} expired without broadcast', + ); + _payjoinSubscription?.cancel(); + // Clear the provisional txId AND payjoinSender so a retry starts + // clean: signTransaction set state.txId = originalTxId up front, + // and broadcastTransaction early-returns while txId != null — so + // leaving them set would permanently short-circuit the retry's + // broadcast. Nulling both lets createTransaction/signTransaction + // re-run the payjoin branch from scratch. + emit( + state.copyWith( + txId: null, + payjoinSender: null, + step: SendStep.confirm, + confirmTransactionException: ConfirmTransactionException( + 'Payjoin expired and the transaction could not be broadcast', + isBroadcastFailure: true, + ), + ), + ); + } else { + emit(state.copyWith(payjoinSender: payjoin)); + } + }); + } + void _watchWalletTransactionByTxId({ required String walletId, required String txId, diff --git a/lib/features/send/presentation/bloc/send_state.dart b/lib/features/send/presentation/bloc/send_state.dart index 7a285568e6..5d409793ec 100644 --- a/lib/features/send/presentation/bloc/send_state.dart +++ b/lib/features/send/presentation/bloc/send_state.dart @@ -70,6 +70,10 @@ abstract class SendState with _$SendState { Wallet? selectedWallet, @Default(false) bool isWalletManuallySelected, bool? isToSelf, + // Fail-closed default: until getCurrencies()/onCurrencyChanged() have + // fetched settings at least once, no payjoin is attempted. Mirrors + // SettingsEntity.isPayjoinEnabled. + @Default(false) bool payjoinGloballyEnabled, @Default('') String amount, int? confirmedAmountSat, BitcoinUnit? bitcoinUnit, @@ -172,6 +176,25 @@ abstract class SendState with _$SendState { /// Whether we have a valid payment request bool get hasValidPaymentRequest => paymentRequest != null; + /// Single source of truth for whether a payjoin will actually be attempted + /// for this send — same pattern as [Payjoin.canManuallyBroadcastOriginal], + /// which unifies a button's visibility and its action guard. Used BOTH to + /// gate `signTransaction`'s payjoin branch and to show the "a payjoin will + /// be attempted" indicator on the confirm screen, so the two can never + /// disagree. + /// + /// Gated on [Wallet.signsLocally]: a hardware/remote-signer wallet + /// (Ledger/BitBox) never reaches `signTransaction`'s payjoin branch (the + /// confirm screen swaps in a device-specific sign button for those + /// wallets instead), so without this check the indicator could promise a + /// payjoin that structurally can never happen for that wallet class. + bool get willAttemptPayjoin => + payjoinGloballyEnabled && + (selectedWallet?.signsLocally ?? false) && + isToSelf != true && + paymentRequest is Bip21PaymentRequest && + (paymentRequest! as Bip21PaymentRequest).pj.isNotEmpty; + String get paymentRequestAddress { if (paymentRequest == null) { return copiedRawPaymentRequest.isNotEmpty diff --git a/lib/features/send/send_locator.dart b/lib/features/send/send_locator.dart index 3793e64cad..2da8bdc34a 100644 --- a/lib/features/send/send_locator.dart +++ b/lib/features/send/send_locator.dart @@ -6,6 +6,7 @@ import 'package:bb_mobile/core/exchange/domain/usecases/get_available_currencies import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart'; import 'package:bb_mobile/core/payjoin/domain/repositories/payjoin_repository.dart'; import 'package:bb_mobile/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dart'; import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart'; import 'package:bb_mobile/core/swaps/data/repository/boltz_swap_repository.dart'; import 'package:bb_mobile/core/swaps/domain/usecases/create_chain_swap_to_external_usecase.dart'; @@ -172,6 +173,7 @@ class SendLocator { getSwapLimitsUsecase: locator(), watchSwapUsecase: locator(), sendWithPayjoinUsecase: locator(), + watchPayjoinUsecase: locator(), watchFinishedWalletSyncsUsecase: locator(), decodeInvoiceUsecase: locator(), diff --git a/lib/features/send/ui/screens/send_screen.dart b/lib/features/send/ui/screens/send_screen.dart index d2f5ff4dcb..da34aa4f57 100644 --- a/lib/features/send/ui/screens/send_screen.dart +++ b/lib/features/send/ui/screens/send_screen.dart @@ -23,6 +23,7 @@ import 'package:bb_mobile/core/widgets/segment/segmented_full.dart'; import 'package:bb_mobile/core/widgets/snackbar_utils.dart'; import 'package:bb_mobile/core/widgets/text/text.dart'; import 'package:bb_mobile/core/widgets/tiles/bordered_tappable_tile.dart'; +import 'package:bb_mobile/core/widgets/timers/countdown.dart'; import 'package:bb_mobile/features/labels/ui/label_entry_bottom_sheet.dart'; import 'package:bb_mobile/features/bitbox/ui/bitbox_router.dart'; import 'package:bb_mobile/features/bitbox/ui/screens/bitbox_action_screen.dart'; @@ -1005,10 +1006,21 @@ class _OnchainTransactionReview extends StatelessWidget { (SendCubit cubit) => cubit.state.isToSelf == true, ); final label = context.select((SendCubit cubit) => cubit.state.label); + final willAttemptPayjoin = context.select( + (SendCubit cubit) => cubit.state.willAttemptPayjoin, + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (willAttemptPayjoin) ...[ + InfoCard( + description: context.loc.sendPayjoinWillBeAttempted, + tagColor: context.appColors.secondary, + bgColor: context.appColors.onSecondary, + ), + const Gap(16), + ], CommonOnchainSendInfoSection( sendWalletLabel: selectedWallet?.displayLabel(context) ?? '', receiveWalletLabel: paymentRequestAddress, @@ -1638,12 +1650,29 @@ class SendSendingScreen extends StatelessWidget { final isPayjoin = context.select( (SendCubit cubit) => cubit.state.payjoinSender != null, ); + // Same canonical getter the manual-fallback button on the transaction + // details screen is gated on (Payjoin.canManuallyBroadcastOriginal) — + // reusing it here, rather than a hand-rolled `proposalPsbt == null` + // check, means this countdown can never linger past the point where a + // proposal has arrived or the session has expired. + final showFallbackCountdown = context.select( + (SendCubit cubit) => + cubit.state.payjoinSender?.canManuallyBroadcastOriginal ?? false, + ); + final payjoinExpiresAt = context.select( + (SendCubit cubit) => cubit.state.payjoinSender?.expiresAt, + ); + // Computed once per build — acceptable, the screen rebuilds on each cubit + // emit; deliberately not adding a ticker to the bloc for this. + final isImminent = + payjoinExpiresAt != null && + payjoinExpiresAt.difference(DateTime.now()) <= const Duration(hours: 1); return Scaffold( appBar: AppBar( forceMaterialTransparency: true, automaticallyImplyLeading: false, - flexibleSpace: const TopBar(title: 'Send'), + flexibleSpace: TopBar(title: context.loc.sendTitle), actions: [ CloseButton( onPressed: () => context.goNamed(WalletRoute.walletHome.name), @@ -1710,6 +1739,30 @@ class SendSendingScreen extends StatelessWidget { maxLines: 4, textAlign: TextAlign.center, ), + if (showFallbackCountdown && + payjoinExpiresAt != null && + isImminent) ...[ + const Gap(8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + BBText( + context.loc.sendPayjoinFallbackCountdown, + style: context.font.bodyMedium, + color: context.appColors.secondary, + ), + const Gap(4), + Countdown( + until: payjoinExpiresAt.add( + const Duration( + seconds: PayjoinConstants.directoryPollingInterval, + ), + ), + onTimeout: () {}, + ), + ], + ), + ], ], ], ), @@ -1850,11 +1903,28 @@ class SendSucessScreen extends StatelessWidget { context.loc.sendSwapWillTakeTime, style: context.font.labelSmall, ), - ] else + ] else ...[ BBText( context.loc.sendSuccessfullySent, style: context.font.bodyLarge, ), + // A payjoin send that completed via the plain-broadcast + // fallback (receiver declined/expired or the payjoin + // negotiation failed). The user explicitly expected a + // payjoin, so say that it didn't happen instead of + // presenting the fallback as indistinguishable from a + // successful payjoin. + if (payjoin != null && payjoin.isAborted) ...[ + const Gap(8), + BBText( + context.loc.sendSentWithoutPayjoin, + style: context.font.bodyMedium, + color: context.appColors.secondary, + maxLines: 4, + textAlign: TextAlign.center, + ), + ], + ], const Gap(8), BBText( amount, @@ -1906,10 +1976,22 @@ class SendSucessScreen extends StatelessWidget { }, ); } else if (payjoin != null) { + // Navigate by the on-chain txid, NOT payjoin.id: a + // sender's id IS the full BIP21 URI (address+amount) and + // would leak into the router location string. By this + // point the session is terminal, so txId (real payjoin) + // or originalTxId (fallback) is always the broadcast tx — + // and landing on the real transaction is better UX than + // the session placeholder. context.pushNamed( - TransactionsRoute.payjoinTransactionDetails.name, - pathParameters: {'payjoinId': payjoin.id}, - queryParameters: {'returnHome': 'true'}, + TransactionsRoute.transactionDetails.name, + pathParameters: { + 'txId': payjoin.txId ?? payjoin.originalTxId, + }, + queryParameters: { + 'walletId': payjoin.walletId, + 'returnHome': 'true', + }, ); } }, diff --git a/lib/features/transactions/application/usecases/export_transactions_csv_usecase.dart b/lib/features/transactions/application/usecases/export_transactions_csv_usecase.dart index fa16e81cc5..6e11baa44e 100644 --- a/lib/features/transactions/application/usecases/export_transactions_csv_usecase.dart +++ b/lib/features/transactions/application/usecases/export_transactions_csv_usecase.dart @@ -21,8 +21,15 @@ class ExportTransactionsCsvUsecase { final transactions = await _getTransactionsUsecase.execute(); + // Preserve the input's UTC-ness when rounding up to the next day: + // building a plain (local) DateTime from a UTC end's wall-clock fields + // shifted the inclusive-day boundary by the machine's UTC offset, so + // the same export included or excluded edge transactions depending on + // the device's timezone. final exclusiveEnd = end == null ? null + : end.isUtc + ? DateTime.utc(end.year, end.month, end.day + 1) : DateTime(end.year, end.month, end.day + 1); final filtered = transactions.where((tx) { diff --git a/lib/features/transactions/domain/entities/transaction.dart b/lib/features/transactions/domain/entities/transaction.dart index e11df4c287..9ad3e8e44d 100644 --- a/lib/features/transactions/domain/entities/transaction.dart +++ b/lib/features/transactions/domain/entities/transaction.dart @@ -51,6 +51,69 @@ sealed class Transaction with _$Transaction { isOngoingPayjoin && payjoin is PayjoinReceiver; bool get isOngoingPayjoinSender => isOngoingPayjoin && payjoin is PayjoinSender; + + /// The payjoin status to DISPLAY, derived from what actually happened + /// on-chain rather than from the session row alone. The session's + /// persisted status can lag reality: completion/abort detection runs on + /// background polls in the payjoin repository, so right after a payment + /// lands the row may still say requested/proposed while the broadcast + /// transaction is already visible in the wallet. When the wallet + /// transaction is present, its txid is authoritative: + /// - it IS the payjoin transaction → the negotiation completed; + /// - it IS the original transaction → the payjoin was aborted and the + /// payment fell back to a plain broadcast. + /// Falls back to the session status when there is no wallet transaction + /// (nothing broadcast yet, or not synced in) — and null when this + /// transaction has no payjoin at all. + PayjoinStatus? get displayPayjoinStatus { + final pj = payjoin; + if (pj == null) return null; + final walletTxId = walletTransaction?.txId; + if (walletTxId != null) { + if (walletTxId == pj.txId) return PayjoinStatus.completed; + if (walletTxId == pj.originalTxId && !pj.isCompleted) { + return PayjoinStatus.aborted; + } + } + return pj.status; + } + + /// The mining fee (sats) deducted from a completed payjoin receive, + /// paying for the input the receiver contributed to the transaction + /// (BIP78). `null` unless this is a payjoin actually reflected in a + /// broadcast transaction, on the receive side, with a positive gap + /// between the amount the sender negotiated ([Payjoin.amountSat]) and the + /// amount the wallet actually sees ([WalletTransaction.amountSat]). + /// + /// The applicability check deliberately mirrors the existing "is this + /// payjoin done" display heuristic used elsewhere + /// (transaction_details_table.dart's status row: isCompleted || + /// (proposed && the broadcast tx IS the proposal)) rather than a strict + /// `isCompleted` check: without the receiver-side watch-for-broadcast + /// this branch doesn't add, a receiver session may never reach + /// `completed` even once its real payjoin transaction has landed in the + /// wallet — a strict check would never show this row for a receiver at + /// all. The proposed case additionally requires the wallet transaction's + /// txid to match the session's proposal txid: sessions are also joined + /// to their ORIGINAL transaction (see LocalPayjoinDatasource.fetchByTxId), + /// and an original that landed on-chain is a plain fallback — no input + /// was contributed, so no fee-contribution row must appear for it. + int? get payjoinFeeContributionSat { + final p = payjoin; + final wt = walletTransaction; + if (p is! PayjoinReceiver || wt == null) return null; + final isRealPayjoinBroadcast = + p.isCompleted || + (p.status == PayjoinStatus.proposed && + p.txId != null && + p.txId == wt.txId); + if (!isRealPayjoinBroadcast) return null; + final expectedAmountSat = p.amountSat; + if (expectedAmountSat == null) return null; + final gap = expectedAmountSat - wt.amountSat; + return gap > 0 ? gap : null; + } + bool get isOrder => order != null; bool get isBuyOrder => order is BuyOrder; bool get isSellOrder => order is SellOrder; diff --git a/lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart b/lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart index b6c82cbc37..9cd4e65cfe 100644 --- a/lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart +++ b/lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart @@ -13,6 +13,7 @@ import 'package:bb_mobile/core/utils/logger.dart'; import 'package:bb_mobile/core/utils/result.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_by_tx_id_usecase.dart'; import 'package:bb_mobile/features/labels/labels_facade.dart'; @@ -29,6 +30,7 @@ class TransactionDetailsCubit extends Cubit { TransactionDetailsCubit({ required this._getWalletUsecase, required this._getTransactionsByTxIdUsecase, + required this._getWalletTransactionUsecase, required this._watchWalletTransactionByTxIdUsecase, required this._getSwapUsecase, required this._getPayjoinByIdUsecase, @@ -42,6 +44,7 @@ class TransactionDetailsCubit extends Cubit { final GetWalletUsecase _getWalletUsecase; final GetTransactionsByTxIdUsecase _getTransactionsByTxIdUsecase; + final GetWalletTransactionUsecase _getWalletTransactionUsecase; final WatchWalletTransactionByTxIdUsecase _watchWalletTransactionByTxIdUsecase; final GetSwapUsecase _getSwapUsecase; @@ -60,6 +63,11 @@ class TransactionDetailsCubit extends Cubit { StreamSubscription? _payjoinTxSubscription; StreamSubscription? _payjoinOriginalTxSubscription; + // The payjoin id _payjoinSubscription is currently listening to on the + // by-wallet-tx path, so reloads triggered by its own events don't + // needlessly cancel and re-create the same subscription. + String? _watchedPayjoinId; + @override Future close() async { await Future.wait([ @@ -73,7 +81,10 @@ class TransactionDetailsCubit extends Cubit { } Future initByWalletTxId(String txId, {required String walletId}) async { - // Start monitoring the wallet transaction for updates. + // Start monitoring the wallet transaction for updates. Cancel any + // previous watcher first: this is also reached from the by-payjoin-id + // path once the broadcast transaction becomes visible. + await _walletTransactionSubscription?.cancel(); _walletTransactionSubscription = _watchWalletTransactionByTxIdUsecase .execute(txId: txId, walletId: walletId) .listen((_) => _loadDetailsByWalletTxId(txId, walletId: walletId)); @@ -133,6 +144,22 @@ class TransactionDetailsCubit extends Cubit { swapClaimedAmountSat: await _counterpartAmountForSwap(swap), ), ); + + // If this transaction belongs to a payjoin session, keep the details + // live on payjoin events too — not just on wallet syncs. The session's + // terminal transitions (fallback broadcast, completion on broadcast) + // happen in the payjoin repository long after this screen was opened, + // and without this the screen only refreshed on the next wallet sync + // (observed live: a stale "Send without payjoin" button lingering for + // ~a minute after the fallback had already broadcast the original). + final payjoin = transaction.payjoin; + if (payjoin != null) { + _watchPayjoinForWalletTx( + payjoinId: payjoin.id, + txId: txId, + walletId: walletId, + ); + } } on TransactionNotFoundError catch (e) { emit(state.copyWith(notFoundError: e)); } catch (e) { @@ -140,6 +167,46 @@ class TransactionDetailsCubit extends Cubit { } } + /// Reloads the by-wallet-tx details whenever the given payjoin session + /// emits an update. Payjoin state lives in the local database, so the + /// reload is instant — the manual-broadcast button and the payjoin status + /// row react the moment the repository resolves the session instead of + /// waiting for a wallet sync to trigger the transaction watcher. + /// + /// On a terminal event (aborted/completed/expired) a targeted sync of this + /// wallet is also fired when the broadcast transaction isn't visible as a + /// wallet transaction yet, so the screen swaps from payjoin-only data to + /// the real transaction promptly instead of at the next scheduled sync. + void _watchPayjoinForWalletTx({ + required String payjoinId, + required String txId, + required String walletId, + }) { + if (_watchedPayjoinId == payjoinId) return; + _watchedPayjoinId = payjoinId; + unawaited(_payjoinSubscription?.cancel()); + _payjoinSubscription = _watchPayjoinUsecase + .execute(ids: [payjoinId]) + .listen((payjoin) async { + // The payjoin repository's timers outlive this cubit; an event can + // arrive after close() (see ReceiveBloc/SendCubit's identical guard). + if (isClosed) return; + await _loadDetailsByWalletTxId(txId, walletId: walletId); + if (isClosed) return; + if (!payjoin.isOngoing && + state.transaction?.walletTransaction == null) { + unawaited( + _getWalletUsecase.execute(walletId, sync: true).catchError(( + Object e, + ) { + log.warning('Failed to sync wallet after payjoin event: $e'); + return null; + }), + ); + } + }); + } + /// The exact amount returned on the recovered chain swap's *counterpart* leg — /// what the user actually received. The canonical tx shown from the send /// wallet is the lockup leg (its amount is what was SENT), so the received @@ -248,6 +315,11 @@ class TransactionDetailsCubit extends Cubit { } Future initByPayjoinId(String payjoinId) async { + // Cancel any prior subscription (a re-entry, or one armed by the + // by-wallet-tx path) before replacing it — otherwise the old listener + // leaks and keeps firing duplicate _loadDetailsByPayjoinId runs. + _watchedPayjoinId = null; + await _payjoinSubscription?.cancel(); _payjoinSubscription = _watchPayjoinUsecase .execute(ids: [payjoinId]) .listen((_) => _loadDetailsByPayjoinId(payjoinId)); @@ -260,12 +332,53 @@ class TransactionDetailsCubit extends Cubit { try { final payjoin = await _getPayjoinByIdUsecase.execute(payjoinId); + // The broadcast transaction (the payjoin one, or the original on a + // fallback) is usually already in the local wallet database by the + // time this screen opens — the repository fires a targeted sync right + // after any broadcast. Resolve it NOW instead of waiting for the next + // organic sync to trigger the watchers below: without this the screen + // sat on payjoin-session-only data (a stale "requested"/"proposed" + // status and no transaction) even though the payment was already + // on-chain (observed live on both sides of a fallback). + var broadcastTxId = await _broadcastTxIdForPayjoin(payjoin); + if (broadcastTxId == null && !payjoin.isOngoing) { + // Resolved session whose broadcast isn't visible locally yet (the + // user tapped "view details" within seconds of the broadcast, before + // any sync pulled it in). Force a DIRECT sync'd lookup — the + // repository's per-transaction sync path is not routed through the + // sync coordinator, so it can't be throttled away — and wait for it, + // so the user lands straight on the transaction view instead of a + // payjoin-session placeholder that swaps out moments later + // (observed live on the receiver side of an aborted payjoin). + broadcastTxId = await _syncBroadcastTxForPayjoin(payjoin); + } + if (broadcastTxId != null) { + // Reset so _loadDetailsByWalletTxId re-arms its own payjoin watcher + // after this by-payjoin-id one is cancelled. Also cancel the two + // per-txid watchers a previous pass may have armed — otherwise they + // keep watching the same txid as _walletTransactionSubscription and + // double-reload once it lands. + _watchedPayjoinId = null; + await _payjoinSubscription?.cancel(); + await _payjoinTxSubscription?.cancel(); + await _payjoinOriginalTxSubscription?.cancel(); + await initByWalletTxId(broadcastTxId, walletId: payjoin.walletId); + return; + } + if (payjoin.txId != null) { // Listen for the payjoin transaction to be broadcasted. await _payjoinTxSubscription?.cancel(); _payjoinTxSubscription = _watchWalletTransactionByTxIdUsecase .execute(txId: payjoin.txId!, walletId: payjoin.walletId) .listen((_) async { + // An event can arrive around close() (the usecase stream and the + // repo's watchers outlive this cubit); never emit on a closed + // cubit (_loadDetailsByWalletTxId emits). + if (isClosed) return; + // Reset so _loadDetailsByWalletTxId re-arms its own payjoin + // watcher after this by-payjoin-id one is cancelled. + _watchedPayjoinId = null; await _payjoinSubscription?.cancel(); await _loadDetailsByWalletTxId( payjoin.txId!, @@ -279,6 +392,9 @@ class TransactionDetailsCubit extends Cubit { _payjoinOriginalTxSubscription = _watchWalletTransactionByTxIdUsecase .execute(txId: payjoin.originalTxId!, walletId: payjoin.walletId) .listen((_) async { + // See the txId watcher above. + if (isClosed) return; + _watchedPayjoinId = null; await _payjoinSubscription?.cancel(); await _loadDetailsByWalletTxId( payjoin.originalTxId!, @@ -294,11 +410,75 @@ class TransactionDetailsCubit extends Cubit { wallet: wallet, ), ); + + // The session is resolved but its broadcast transaction isn't visible + // in the local wallet database yet — fire a targeted sync so the + // watchers armed above swap this screen to the real transaction + // promptly instead of at the next scheduled sync (same gap + // _watchPayjoinForWalletTx closes on the by-wallet-tx path). + if (!payjoin.isOngoing) { + unawaited( + _getWalletUsecase.execute(payjoin.walletId, sync: true).catchError(( + Object e, + ) { + log.warning('Failed to sync wallet for resolved payjoin: $e'); + return null; + }), + ); + } } catch (e) { emit(state.copyWith(err: e)); } } + /// The txid of this payjoin session's transaction that actually reached + /// the chain AND is already visible as a wallet transaction locally — the + /// payjoin transaction when the negotiation completed, or the original + /// transaction when the session fell back to a plain broadcast. Null while + /// neither is visible yet (session still ongoing, or the wallet hasn't + /// synced the broadcast in). + Future _broadcastTxIdForPayjoin(Payjoin payjoin) async { + for (final txId in [payjoin.txId, payjoin.originalTxId]) { + if (txId == null) continue; + try { + final transactions = await _getTransactionsByTxIdUsecase.execute(txId); + final isVisibleInWallet = transactions.any( + (tx) => + tx.walletId == payjoin.walletId && tx.walletTransaction != null, + ); + if (isVisibleInWallet) return txId; + } catch (_) { + // Nothing found for this txid — try the next candidate. + } + } + return null; + } + + /// Same candidates as [_broadcastTxIdForPayjoin], but each lookup forces a + /// direct electrum-backed sync first, pulling a just-broadcast transaction + /// into the local wallet database on demand. Bounded by one sync per + /// candidate; best-effort — a failed lookup just means the watchers armed + /// by the caller resolve it later. + Future _syncBroadcastTxForPayjoin(Payjoin payjoin) async { + for (final txId in [payjoin.txId, payjoin.originalTxId]) { + if (txId == null) continue; + final result = await _getWalletTransactionUsecase.execute( + txId: txId, + walletId: payjoin.walletId, + sync: true, + ); + switch (result) { + case Ok(:final value): + if (value != null) return txId; + case Err(:final failure): + log.warning( + 'Forced lookup of payjoin broadcast tx failed: ${failure.logMessage}', + ); + } + } + return null; + } + Future initByOrderId(String orderId) async { await _loadDetailsByOrderId(orderId); } @@ -372,6 +552,17 @@ class TransactionDetailsCubit extends Cubit { try { final payjoin = state.payjoin; if (payjoin == null) return; + // Backstop using the SAME canonical Payjoin.canManuallyBroadcastOriginal + // getter TransactionDetailsScreen's button visibility is gated on (see + // its doc comment for the exact semantics): a manual rebroadcast here + // would otherwise either be a no-op or, worse, race/replace an + // already-broadcast real payjoin transaction that spends the same + // inputs at a different fee. Deriving both the button's visibility and + // this action from the one getter means they can't drift out of sync — + // this is the backstop against a stale snapshot letting a tap through + // anyway, observed live (a second "Send without payjoin" tap + // re-broadcast an already-completed session's original transaction). + if (!payjoin.canManuallyBroadcastOriginal) return; emit(state.copyWith(isBroadcastingPayjoinOriginalTx: true, err: null)); final updatedPayjoin = await _broadcastOriginalTransactionUsecase.execute( payjoin, diff --git a/lib/features/transactions/transactions_locator.dart b/lib/features/transactions/transactions_locator.dart index 0e31015588..361b46349f 100644 --- a/lib/features/transactions/transactions_locator.dart +++ b/lib/features/transactions/transactions_locator.dart @@ -12,6 +12,7 @@ import 'package:bb_mobile/core/swaps/domain/usecases/process_swap_usecase.dart'; import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart'; import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/watch_started_wallet_syncs_usecase.dart'; @@ -109,6 +110,7 @@ class TransactionsLocator { () => TransactionDetailsCubit( getWalletUsecase: locator(), getTransactionsByTxIdUsecase: locator(), + getWalletTransactionUsecase: locator(), watchWalletTransactionByTxIdUsecase: locator(), getSwapUsecase: locator(), diff --git a/lib/features/transactions/ui/screens/transaction_details_screen.dart b/lib/features/transactions/ui/screens/transaction_details_screen.dart index 50f065d0ed..a3263b7638 100644 --- a/lib/features/transactions/ui/screens/transaction_details_screen.dart +++ b/lib/features/transactions/ui/screens/transaction_details_screen.dart @@ -1,5 +1,4 @@ import 'package:bb_mobile/core/exchange/domain/entity/order.dart'; -import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; import 'package:bb_mobile/core/themes/app_theme.dart'; import 'package:bb_mobile/core/utils/build_context_x.dart'; import 'package:bb_mobile/core/utils/logger.dart' show log; @@ -48,9 +47,15 @@ class TransactionDetailsScreen extends StatelessWidget { final wallet = context.select( (TransactionDetailsCubit bloc) => bloc.state.wallet, ); - final isPayjoinCompleted = context.select( + // The single source of truth this button's visibility must agree with — + // see Payjoin.canManuallyBroadcastOriginal's doc comment. Deriving both + // from the same getter as the cubit's own guard means the button can + // never be shown for a session where tapping it would just silently + // no-op (observed live before this was unified: a stale-looking button + // let a tap through that re-broadcast an already-completed session). + final canManuallyBroadcastOriginal = context.select( (TransactionDetailsCubit bloc) => - bloc.state.payjoin?.status == PayjoinStatus.completed, + bloc.state.payjoin?.canManuallyBroadcastOriginal ?? false, ); final isBroadcastingPayjoinOriginalTx = context.select( (TransactionDetailsCubit bloc) => @@ -172,7 +177,7 @@ class TransactionDetailsScreen extends StatelessWidget { ], const Gap(16), if (tx?.isOngoingPayjoinSender == true && - !isPayjoinCompleted) ...[ + canManuallyBroadcastOriginal) ...[ const SenderBroadcastPayjoinOriginalTxButton(), const Gap(24), ], diff --git a/lib/features/transactions/ui/widgets/transaction_details_status_label.dart b/lib/features/transactions/ui/widgets/transaction_details_status_label.dart index 7995a7bf18..d659ab95b5 100644 --- a/lib/features/transactions/ui/widgets/transaction_details_status_label.dart +++ b/lib/features/transactions/ui/widgets/transaction_details_status_label.dart @@ -18,8 +18,13 @@ class TransactionDetailsStatusLabel extends StatelessWidget { final swap = transaction?.swap; final order = transaction?.order; final isOrder = transaction?.isOrder; + // Display status, not the raw session status: derived from the broadcast + // transaction when it is already visible, so a stale session row + // (completion detection lagging on a background poll) can't surface + // "requested"/"proposed" for a payment that's already on-chain. final payjoinStatus = context.select( - (TransactionDetailsCubit bloc) => bloc.state.payjoin?.status, + (TransactionDetailsCubit bloc) => + bloc.state.transaction?.displayPayjoinStatus, ); return BBText( @@ -47,6 +52,8 @@ class TransactionDetailsStatusLabel extends StatelessWidget { ? order.orderType.value : payjoinStatus == PayjoinStatus.completed ? context.loc.transactionStatusPayjoinCompleted + : payjoinStatus == PayjoinStatus.aborted + ? context.loc.transactionStatusPayjoinAborted : payjoinStatus == PayjoinStatus.requested ? context.loc.transactionStatusPayjoinRequested : transaction?.isIncoming == true diff --git a/lib/features/transactions/ui/widgets/transaction_details_table.dart b/lib/features/transactions/ui/widgets/transaction_details_table.dart index 34cec526cc..63caea9fd1 100644 --- a/lib/features/transactions/ui/widgets/transaction_details_table.dart +++ b/lib/features/transactions/ui/widgets/transaction_details_table.dart @@ -1004,14 +1004,23 @@ class TransactionDetailsTable extends StatelessWidget { if (payjoin != null) ...[ DetailsTableItem( label: context.loc.transactionDetailLabelPayjoinStatus, - displayValue: - payjoin.isCompleted || - (payjoin.status == PayjoinStatus.proposed && - walletTransaction != null) - ? context.loc.transactionDetailLabelPayjoinCompleted - : payjoin.isExpired - ? context.loc.transactionDetailLabelPayjoinExpired - : payjoin.status.name, + // Display status, derived from the broadcast transaction when it + // is visible (see Transaction.displayPayjoinStatus): a stale + // session row can't surface a raw "requested"/"proposed" for a + // payment already on-chain. The exhaustive switch makes a new + // PayjoinStatus a compile error rather than a leaked enum name. + displayValue: switch (transaction!.displayPayjoinStatus!) { + PayjoinStatus.completed => + context.loc.transactionDetailLabelPayjoinCompleted, + PayjoinStatus.aborted => + context.loc.transactionDetailLabelPayjoinAborted, + PayjoinStatus.expired => + context.loc.transactionDetailLabelPayjoinExpired, + PayjoinStatus.started || + PayjoinStatus.requested || + PayjoinStatus.proposed => + context.loc.transactionDetailLabelPayjoinInProgress, + }, ), DetailsTableItem( label: context.loc.transactionDetailLabelPayjoinCreationTime, @@ -1019,6 +1028,26 @@ class TransactionDetailsTable extends StatelessWidget { 'MMM d, y, h:mm a', ).format(payjoin.createdAt), ), + if (transaction.payjoinFeeContributionSat != null) + DetailsTableItem( + label: context.loc.transactionDetailLabelPayjoinFeeContribution, + displayValue: bitcoinUnit == BitcoinUnit.sats + ? FormatAmount.sats( + transaction.payjoinFeeContributionSat!, + ).toUpperCase() + : FormatAmount.btc( + ConvertAmount.satsToBtc( + transaction.payjoinFeeContributionSat!, + ), + ).toUpperCase(), + expandableChild: BBText( + context.loc.transactionPayjoinFeeContributionExplanation, + style: context.font.bodySmall?.copyWith( + color: context.appColors.secondary, + ), + maxLines: 5, + ), + ), ], ], ); diff --git a/lib/features/wallet/presentation/bloc/wallet_state.dart b/lib/features/wallet/presentation/bloc/wallet_state.dart index f7edc98863..029c335ada 100644 --- a/lib/features/wallet/presentation/bloc/wallet_state.dart +++ b/lib/features/wallet/presentation/bloc/wallet_state.dart @@ -79,8 +79,4 @@ sealed class WalletState with _$WalletState { liquidWallet.balanceSat.toInt(), ); } - - bool showAutoSwapActiveStatus() { - return autoSwapSettings != null && autoSwapSettings!.enabled; - } } diff --git a/lib/features/wallet/ui/screens/wallet_home_screen.dart b/lib/features/wallet/ui/screens/wallet_home_screen.dart index ff5d5eece8..1ac394da9d 100644 --- a/lib/features/wallet/ui/screens/wallet_home_screen.dart +++ b/lib/features/wallet/ui/screens/wallet_home_screen.dart @@ -1,5 +1,6 @@ import 'package:bb_mobile/core/themes/colors.dart'; import 'package:bb_mobile/core/widgets/bb_pullable_body.dart'; +import 'package:bb_mobile/features/announcements/ui/widgets/announcement_carousel.dart'; import 'package:bb_mobile/features/wallet/presentation/bloc/wallet_bloc.dart'; import 'package:bb_mobile/features/wallet/ui/wallet_router.dart'; import 'package:bb_mobile/features/wallet/ui/widgets/auto_swap_fee_warning.dart'; @@ -136,6 +137,7 @@ class _WalletHomeScreenState extends State { onRefresh: () => context.read().refresh(), slivers: [ const SliverToBoxAdapter(child: WalletHomeTopSection()), + const SliverToBoxAdapter(child: AnnouncementCarousel()), const SliverToBoxAdapter(child: HomeWarnings()), const SliverToBoxAdapter(child: AutoSwapFeeWarning()), const SliverToBoxAdapter(child: HomeConsolidationBanner()), diff --git a/lib/features/wallet/ui/widgets/home_errors.dart b/lib/features/wallet/ui/widgets/home_errors.dart index a50b23aa59..44d0e0b813 100644 --- a/lib/features/wallet/ui/widgets/home_errors.dart +++ b/lib/features/wallet/ui/widgets/home_errors.dart @@ -3,7 +3,6 @@ import 'package:bb_mobile/core/widgets/cards/autoswap_warning_card.dart'; import 'package:bb_mobile/core/widgets/cards/backup_card.dart'; import 'package:bb_mobile/core/widgets/cards/info_card.dart'; import 'package:bb_mobile/features/backup_settings/ui/backup_settings_router.dart'; -import 'package:bb_mobile/features/settings/ui/settings_router.dart'; import 'package:bb_mobile/features/wallet/presentation/bloc/wallet_bloc.dart'; import 'package:bb_mobile/features/wallet/ui/widgets/autoswap_warning_bottom_sheet.dart'; import 'package:flutter/material.dart'; @@ -22,20 +21,16 @@ class HomeWarnings extends StatelessWidget { previous.isOnLegacyStorage != current.isOnLegacyStorage || previous.showAutoSwapDefaultEnabledWarning() != current.showAutoSwapDefaultEnabledWarning() || - previous.showAutoSwapActiveStatus() != - current.showAutoSwapActiveStatus() || previous.warnings != current.warnings, builder: (context, state) { final showBackupWarning = state.hasNoBackup() && !state.isOnLegacyStorage; final showAutoSwapDefaultEnabledWarning = state .showAutoSwapDefaultEnabledWarning(); - final showAutoSwapActiveStatus = state.showAutoSwapActiveStatus(); final serverWarning = state.warnings; if (!showBackupWarning && !showAutoSwapDefaultEnabledWarning && - !showAutoSwapActiveStatus && serverWarning.isEmpty) { return const SizedBox.shrink(); } @@ -59,15 +54,6 @@ class HomeWarnings extends StatelessWidget { ), ], - if (showAutoSwapActiveStatus) ...[ - if (showBackupWarning) const Gap(5), - AutoSwapWarningCard( - isActiveMode: true, - onTap: () => - context.pushNamed(SettingsRoute.autoswapSettings.name), - ), - ], - for (final warning in serverWarning) ...[ const Gap(5), InfoCard( diff --git a/lib/locator.dart b/lib/locator.dart index 4e9664af88..9ae3b593c8 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,11 +1,15 @@ +import 'dart:async'; + import 'package:bb_mobile/core/ark/locator.dart'; import 'package:bb_mobile/core/core_locator.dart'; +import 'package:bb_mobile/core/payjoin/domain/repositories/payjoin_repository.dart'; import 'package:bb_mobile/core/status/status_locator.dart'; import 'package:bb_mobile/core/storage/sqlite_database.dart'; import 'package:bb_mobile/core/sync/sync_locator.dart'; import 'package:bb_mobile/features/address_view/address_view_locator.dart'; import 'package:bb_mobile/features/all_seed_view/all_seed_view_locator.dart'; import 'package:bb_mobile/features/app_startup/app_startup_locator.dart'; +import 'package:bb_mobile/features/announcements/announcements_locator.dart'; import 'package:bb_mobile/features/app_unlock/app_unlock_locator.dart'; import 'package:bb_mobile/features/autoswap/autoswap_locator.dart'; import 'package:bb_mobile/features/backup_settings/backup_settings_locator.dart'; @@ -65,6 +69,15 @@ class AppLocator { CoreLocator.registerUsecases(locator); CoreLocator.registerFrameworks(locator); CoreLocator.registerFacades(locator); + + // Every dependency PayjoinRepositoryImpl needs (wallet repositories, + // settings, the labels facade) is now guaranteed registered — resume any + // unfinished payjoin sessions left over from a previous run. Not awaited: + // this is background work (relay polling, wallet syncs) that must not + // delay app startup. See resumePayjoinsOnStartup's doc for why this + // can't just run in the repository's constructor. + unawaited(locator().resumePayjoinsOnStartup()); + SyncLocator.setup(locator); // Register feature-specific dependencies @@ -89,6 +102,7 @@ class AppLocator { CoinsLocator.setup(locator); ConsolidationLocator.setup(locator); BackupSettingsLocator.setup(locator); + AnnouncementsLocator.setup(locator); TestWalletBackupLocator.setup(locator); ImportWatchOnlyLocator.setup(locator); BroadcastSignedTxLocator.setup(locator); diff --git a/lib/router.dart b/lib/router.dart index 1546e90afa..a7b3e5bb22 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:bb_mobile/core/screens/route_error_screen.dart'; import 'package:bb_mobile/core/themes/app_theme.dart'; import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart'; import 'package:bb_mobile/features/app_unlock/ui/app_unlock_router.dart'; import 'package:bb_mobile/features/ark/router.dart'; import 'package:bb_mobile/features/ark_setup/router.dart'; @@ -75,8 +76,13 @@ class AppRouter { location.contains('/support-chat') || location.contains('/login-support'); - return BlocProvider( - create: (_) => locator(), + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => locator()), + BlocProvider( + create: (_) => locator()..refresh(), + ), + ], child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { diff --git a/localization/app_en.arb b/localization/app_en.arb index eb91c68319..41aefaae5d 100644 --- a/localization/app_en.arb +++ b/localization/app_en.arb @@ -14961,16 +14961,52 @@ "@receivePayjoinFallbackCountdown": { "description": "Label preceding a countdown until the payjoin falls back to a normal payment." }, - "receivePayjoinNoUtxos": "Receive funds first to enable it.\nUTXOs are required to use payjoin.", - "@receivePayjoinNoUtxos": { - "description": "Snackbar shown when user tries to enable payjoin on a wallet without any UTXOs" - }, - "receivePayjoinActivated": "Payjoin activated", - "@receivePayjoinActivated": { - "description": "Message indicating that payjoin is activated for the receive transaction" - }, "statusCheckDisabled": "Disabled", "@statusCheckDisabled": { "description": "Status label on the service status page for a service the user has intentionally turned off (e.g. payjoin disabled in settings)." + }, + "announcementPayjoinTitle": "Increase your privacy", + "@announcementPayjoinTitle": { + "description": "Title of the home announcement inviting the user to enable payjoin for better on-chain privacy." + }, + "announcementPayjoinDescription": "Turn on Payjoin to make your Bitcoin payments more private.", + "@announcementPayjoinDescription": { + "description": "Description of the home announcement inviting the user to enable payjoin." + }, + "announcementAutoswapTitle": "Autoswap is active", + "@announcementAutoswapTitle": { + "description": "Title of the home announcement informing the user that autoswap is currently enabled." + }, + "announcementAutoswapDescription": "Your Bitcoin is automatically swapped to Liquid.", + "@announcementAutoswapDescription": { + "description": "Description of the home announcement informing the user that autoswap is enabled." + }, + "announcementDismissConfirmTitle": "Dismiss this announcement?", + "@announcementDismissConfirmTitle": { + "description": "Title of the confirmation dialog shown before dismissing a home announcement." + }, + "announcementDismissConfirmBody": "Read to learn more, or dismiss to hide it. Dismissed announcements won't show again.", + "@announcementDismissConfirmBody": { + "description": "Body of the confirmation dialog shown before dismissing a home announcement." + }, + "announcementDismissConfirmAction": "Dismiss", + "@announcementDismissConfirmAction": { + "description": "Confirm button label to dismiss a home announcement." + }, + "announcementDismissConfirmRead": "Read", + "@announcementDismissConfirmRead": { + "description": "Button in the announcement dismiss dialog that opens the announcement linked page instead of dismissing." + }, + "receivePayjoinAwaitingFunds": "Payjoin is enabled, but this wallet has no confirmed balance yet to contribute. It will activate once you receive funds.", + "@receivePayjoinAwaitingFunds": { + "description": "Hint on the receive screen explaining why payjoin (enabled in settings) is not yet active for a wallet with no confirmed balance." + }, + "receivePayjoinQrBadge": "Payjoin", + "@receivePayjoinQrBadge": { + "description": "Short badge shown on the receive QR code corner when the address advertises a payjoin endpoint, signalling the sender may propose a payjoin." + }, + "receivePayjoinBelowMinimumAmount": "Payjoin is off for this amount — it is below your Payjoin minimum. Request a larger amount to enable it.", + "@receivePayjoinBelowMinimumAmount": { + "description": "Hint on the receive screen when payjoin is enabled but the requested amount is below the anti-probing minimum, so the QR does not advertise a payjoin endpoint." } } diff --git a/localization/app_fr.arb b/localization/app_fr.arb index 9e879ecf46..3850431740 100644 --- a/localization/app_fr.arb +++ b/localization/app_fr.arb @@ -5275,5 +5275,13 @@ "transactionDetailLabelPayjoinAborted": "Abandonné", "transactionDetailLabelPayjoinFeeContribution": "Contribution aux frais du payjoin", "transactionPayjoinFeeContributionExplanation": "Un payjoin ajoute une de vos pièces comme entrée supplémentaire au paiement afin d'améliorer la confidentialité. Les frais de minage de cette entrée ajoutée sont déduits du montant que vous recevez (BIP78).", - "receivePayjoinFallbackCountdown": "Retour à un paiement normal dans" + "receivePayjoinFallbackCountdown": "Retour à un paiement normal dans", + "announcementPayjoinTitle": "Augmentez votre confidentialité", + "announcementPayjoinDescription": "Activez le Payjoin pour rendre vos paiements Bitcoin plus privés.", + "announcementAutoswapTitle": "Autoswap est activé", + "announcementAutoswapDescription": "Vos bitcoins sont automatiquement convertis vers Liquid.", + "announcementDismissConfirmTitle": "Ignorer cette annonce ?", + "announcementDismissConfirmBody": "Lisez pour en savoir plus, ou ignorez pour la masquer. Les annonces ignorées ne réapparaîtront plus.", + "announcementDismissConfirmAction": "Ignorer", + "announcementDismissConfirmRead": "Lire" } diff --git a/packages/bull_ui/lib/bull_ui.dart b/packages/bull_ui/lib/bull_ui.dart index 60f98bc9a0..b8645279ad 100644 --- a/packages/bull_ui/lib/bull_ui.dart +++ b/packages/bull_ui/lib/bull_ui.dart @@ -15,6 +15,7 @@ export 'package:flutter/widgets.dart' AlwaysScrollableScrollPhysics, AnimatedContainer, AnimatedPositioned, + AnimatedSize, AspectRatio, Axis, Border, @@ -30,6 +31,7 @@ export 'package:flutter/widgets.dart' ConstrainedBox, Container, CrossAxisAlignment, + Curves, EdgeInsets, EdgeInsetsGeometry, Expanded, @@ -47,6 +49,8 @@ export 'package:flutter/widgets.dart' NeverScrollableScrollPhysics, Opacity, Padding, + PageController, + PageView, Positioned, Radius, Row, diff --git a/test/core_test/payjoin/local_payjoin_datasource_test.dart b/test/core_test/payjoin/local_payjoin_datasource_test.dart new file mode 100644 index 0000000000..4d1d7b883e --- /dev/null +++ b/test/core_test/payjoin/local_payjoin_datasource_test.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:bb_mobile/core/payjoin/data/datasources/local_payjoin_datasource.dart'; +import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late SqliteDatabase db; + late LocalPayjoinDatasource datasource; + + setUp(() { + db = SqliteDatabase(NativeDatabase.memory()); + datasource = LocalPayjoinDatasource(db: db); + }); + + tearDown(() async => db.close()); + + final originalTxId = 'a' * 64; + final proposalTxId = 'b' * 64; + + PayjoinReceiverModel buildReceiver({ + String? txId, + String? originalTxIdValue, + bool isAborted = false, + }) => + PayjoinModel.receiver( + id: 'r1', + address: 'bcrt1qaddress', + isTestnet: true, + receiver: '[]', + walletId: 'w1', + pjUri: 'bitcoin:bcrt1qaddress?pj=https://payjo.in/x', + maxFeeRateSatPerVb: BigInt.from(10000), + createdAt: 1700000000, + expireAfterSec: 86400, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + originalTxId: originalTxIdValue, + amountSat: 5000, + txId: txId, + isAborted: isAborted, + ) + as PayjoinReceiverModel; + + group('fetchByTxId', () { + test('finds an aborted session by its ORIGINAL transaction id — the only ' + 'id that exists on-chain for a fallback broadcast (txId is null, no ' + 'proposal was ever broadcast); matching only txId hid the aborted ' + 'outcome from the transaction details entirely', () async { + await datasource.storeReceiver( + buildReceiver(originalTxIdValue: originalTxId, isAborted: true), + ); + + final found = await datasource.fetchByTxId(originalTxId); + + expect(found, hasLength(1)); + expect(found.single.isAborted, isTrue); + }); + + test('still finds a session by its payjoin transaction id', () async { + await datasource.storeReceiver( + buildReceiver(txId: proposalTxId, originalTxIdValue: originalTxId), + ); + + final found = await datasource.fetchByTxId(proposalTxId); + + expect(found, hasLength(1)); + }); + + test('returns nothing for an unrelated transaction id', () async { + await datasource.storeReceiver( + buildReceiver(txId: proposalTxId, originalTxIdValue: originalTxId), + ); + + final found = await datasource.fetchByTxId('c' * 64); + + expect(found, isEmpty); + }); + }); + + group('fetchAll(onlyUnfinished)', () { + test('excludes aborted sessions — they must never be resumed', () async { + await datasource.storeReceiver( + buildReceiver(originalTxIdValue: originalTxId, isAborted: true), + ); + + final unfinished = await datasource.fetchAll(onlyUnfinished: true); + final all = await datasource.fetchAll(); + + expect(unfinished, isEmpty); + expect(all, hasLength(1)); + }); + }); +} diff --git a/test/core_test/payjoin/payjoin_entity_test.dart b/test/core_test/payjoin/payjoin_entity_test.dart new file mode 100644 index 0000000000..a6c9d1d481 --- /dev/null +++ b/test/core_test/payjoin/payjoin_entity_test.dart @@ -0,0 +1,218 @@ +import 'dart:typed_data'; + +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _defaultOriginalTxBytes = Uint8List.fromList([1, 2, 3]); + +PayjoinReceiver _receiver({ + required PayjoinStatus status, + String? txId, + String? proposalPsbt, + // Defaults to a non-null value: once a request has been received the + // receiver holds the sender's original transaction, which + // canManuallyBroadcastOriginal requires. Pass Uint8List(0)-equivalent + // null via [noOriginalTxBytes] for the `started` (no request yet) case. + Uint8List? originalTxBytes, + bool noOriginalTxBytes = false, +}) => + Payjoin.receiver( + status: status, + id: 'pj1', + isTestnet: true, + walletId: 'w1', + pjUri: 'bitcoin:tb1qtest?pj=https://payjo.in', + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + txId: txId, + proposalPsbt: proposalPsbt, + originalTxBytes: noOriginalTxBytes + ? null + : (originalTxBytes ?? _defaultOriginalTxBytes), + ) + as PayjoinReceiver; + +PayjoinSender _sender({ + required PayjoinStatus status, + String? txId, + String? proposalPsbt, +}) => + Payjoin.sender( + status: status, + uri: 'bitcoin:tb1qsender?pj=https://payjo.in', + isTestnet: true, + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: 'orig-txid', + amountSat: 50000, + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + txId: txId, + proposalPsbt: proposalPsbt, + ) + as PayjoinSender; + +void main() { + group('Payjoin.isCompleted/isAborted/isExpired/isOngoing', () { + Payjoin buildReceiver(PayjoinStatus status) => Payjoin.receiver( + status: status, + id: 'r1', + isTestnet: true, + walletId: 'w1', + pjUri: 'bitcoin:addr?pj=https://payjo.in/x', + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 2), + ); + + test('completed', () { + final p = buildReceiver(PayjoinStatus.completed); + expect(p.isCompleted, isTrue); + expect(p.isAborted, isFalse); + expect(p.isExpired, isFalse); + expect(p.isOngoing, isFalse); + }); + + test( + 'aborted — the payjoin did not happen, WE broadcast the original tx', + () { + final p = buildReceiver(PayjoinStatus.aborted); + expect(p.isCompleted, isFalse); + expect(p.isAborted, isTrue); + expect(p.isExpired, isFalse); + expect( + p.isOngoing, + isFalse, + reason: 'aborted is terminal, exactly like completed/expired', + ); + }, + ); + + test('expired — nothing broadcast by us', () { + final p = buildReceiver(PayjoinStatus.expired); + expect(p.isCompleted, isFalse); + expect(p.isAborted, isFalse); + expect(p.isExpired, isTrue); + expect(p.isOngoing, isFalse); + }); + + test('requested/proposed are ongoing', () { + expect(buildReceiver(PayjoinStatus.requested).isOngoing, isTrue); + expect(buildReceiver(PayjoinStatus.proposed).isOngoing, isTrue); + }); + }); + + group('Payjoin.canManuallyBroadcastOriginal', () { + test('receiver: true while waiting (request received, no proposal ' + 'sent)', () { + expect( + _receiver(status: PayjoinStatus.requested).canManuallyBroadcastOriginal, + isTrue, + ); + }); + + test('receiver: false while still started — no original tx to broadcast ' + 'yet (no request received)', () { + expect( + _receiver( + status: PayjoinStatus.started, + noOriginalTxBytes: true, + ).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('receiver: false once a proposal is sent — the sender owns it for ' + 'as long as that takes, with no dead-end that would ever need a ' + 'manual retry', () { + expect( + _receiver( + status: PayjoinStatus.proposed, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('receiver: false once completed (real payjoin)', () { + expect( + _receiver( + status: PayjoinStatus.completed, + txId: 'payjoin-txid', + ).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('receiver: false once aborted (fallback already broadcast)', () { + expect( + _receiver(status: PayjoinStatus.aborted).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('sender: true while waiting (no proposal ever received)', () { + expect( + _sender(status: PayjoinStatus.requested).canManuallyBroadcastOriginal, + isTrue, + ); + }); + + test('sender: false while a proposal is being actively processed ' + '(received, not yet completed or expired)', () { + expect( + _sender( + status: PayjoinStatus.proposed, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('sender: false once completed (real payjoin)', () { + expect( + _sender( + status: PayjoinStatus.completed, + txId: 'payjoin-txid', + ).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('sender: false once aborted (fallback already broadcast)', () { + expect( + _sender(status: PayjoinStatus.aborted).canManuallyBroadcastOriginal, + isFalse, + ); + }); + + test('sender: true once its OWN internal fallback also gave up ' + '(expired, proposalPsbt still set) — no dead-end left, a manual ' + 'retry must still be possible', () { + expect( + _sender( + status: PayjoinStatus.expired, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ).canManuallyBroadcastOriginal, + isTrue, + ); + }); + }); + + group('Payjoin.logRef', () { + test('a receiver id passes through unchanged', () { + // A receiver id is already an opaque sha256 prefix of the pjUri, so it + // is safe to log verbatim and stays stable across restarts. + expect(_receiver(status: PayjoinStatus.requested).logRef, 'pj1'); + }); + + test('a sender logRef is a 16-char lowercase hex hash and never leaks ' + 'the BIP21 uri (which carries the address/amount/endpoint)', () { + final sender = _sender(status: PayjoinStatus.requested); + + expect(sender.logRef, matches(RegExp(r'^[0-9a-f]{16}$'))); + // The raw address must never appear in the log-safe reference. + expect(sender.logRef, isNot(contains('tb1qsender'))); + expect(sender.logRef, isNot(equals(sender.id))); + }); + }); +} diff --git a/test/core_test/payjoin/payjoin_model_test.dart b/test/core_test/payjoin/payjoin_model_test.dart new file mode 100644 index 0000000000..583fa6f3c7 --- /dev/null +++ b/test/core_test/payjoin/payjoin_model_test.dart @@ -0,0 +1,131 @@ +import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PayjoinModel.status derivation', () { + PayjoinReceiverModel buildReceiver({ + bool isCompleted = false, + bool isAborted = false, + bool isExpired = false, + String? proposalPsbt, + dynamic originalTxBytes, + }) => + PayjoinModel.receiver( + id: 'r1', + address: 'addr', + isTestnet: true, + receiver: '[]', + walletId: 'w1', + pjUri: 'bitcoin:addr?pj=https://payjo.in/x', + maxFeeRateSatPerVb: BigInt.from(10000), + createdAt: 0, + expireAfterSec: 86400, + proposalPsbt: proposalPsbt, + isCompleted: isCompleted, + isAborted: isAborted, + isExpired: isExpired, + ) + as PayjoinReceiverModel; + + test('isCompleted takes priority over everything else', () { + final model = buildReceiver( + isCompleted: true, + isAborted: true, + isExpired: true, + ); + expect(model.status, PayjoinStatus.completed); + }); + + test('isAborted takes priority over isExpired', () { + final model = buildReceiver(isAborted: true, isExpired: true); + expect(model.status, PayjoinStatus.aborted); + }); + + test('isExpired when neither completed nor aborted', () { + final model = buildReceiver(isExpired: true); + expect(model.status, PayjoinStatus.expired); + }); + + test('proposed when a proposal exists and nothing terminal is set', () { + final model = buildReceiver(proposalPsbt: 'psbt'); + expect(model.status, PayjoinStatus.proposed); + }); + + test('started when nothing has happened yet', () { + final model = buildReceiver(); + expect(model.status, PayjoinStatus.started); + }); + }); + + group('PayjoinModel.fromReceiverTable/fromSenderTable round-trip ' + 'isExpired/isCompleted/isAborted (regression: these used to be silently ' + 'dropped on every re-fetch, resetting status to "never resolved")', () { + test('fromReceiverTable maps the terminal flags from the row', () { + final row = PayjoinReceiverRow( + id: 'r1', + address: 'addr', + isTestnet: true, + receiver: '[]', + walletId: 'w1', + pjUri: 'bitcoin:addr?pj=https://payjo.in/x', + maxFeeRateSatPerVb: BigInt.from(10000), + createdAt: 0, + expireAfterSec: 86400, + isExpired: false, + isCompleted: true, + isAborted: false, + ); + + final model = PayjoinModel.fromReceiverTable(row); + + expect(model.isCompleted, isTrue); + expect(model.status, PayjoinStatus.completed); + }); + + test('fromReceiverTable maps isAborted from the row', () { + final row = PayjoinReceiverRow( + id: 'r1', + address: 'addr', + isTestnet: true, + receiver: '[]', + walletId: 'w1', + pjUri: 'bitcoin:addr?pj=https://payjo.in/x', + maxFeeRateSatPerVb: BigInt.from(10000), + createdAt: 0, + expireAfterSec: 86400, + isExpired: false, + isCompleted: false, + isAborted: true, + ); + + final model = PayjoinModel.fromReceiverTable(row); + + expect(model.isAborted, isTrue); + expect(model.status, PayjoinStatus.aborted); + }); + + test('fromSenderTable maps the terminal flags from the row', () { + final row = PayjoinSenderRow( + uri: 'bitcoin:addr?pj=https://payjo.in/x', + isTestnet: true, + sender: '[]', + walletId: 'w1', + originalPsbt: 'psbt', + originalTxId: 'a' * 64, + amountSat: 10000, + createdAt: 0, + expireAfterSec: 86400, + isExpired: false, + isCompleted: true, + isAborted: false, + ); + + final model = PayjoinModel.fromSenderTable(row); + + expect(model.isCompleted, isTrue); + expect(model.status, PayjoinStatus.completed); + }); + }); +} diff --git a/test/core_test/payjoin/payjoin_repository_impl_test.dart b/test/core_test/payjoin/payjoin_repository_impl_test.dart new file mode 100644 index 0000000000..8307e0d9f7 --- /dev/null +++ b/test/core_test/payjoin/payjoin_repository_impl_test.dart @@ -0,0 +1,2005 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:bb_mobile/core/blockchain/data/datasources/bdk_bitcoin_blockchain_datasource.dart'; +import 'package:bb_mobile/core/electrum/domain/ports/electrum_servers_port.dart'; +import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart'; +import 'package:bb_mobile/core/entities/signer_entity.dart' show SignerEntity; +import 'package:bb_mobile/core/payjoin/data/datasources/local_payjoin_datasource.dart'; +import 'package:bb_mobile/core/payjoin/data/datasources/pdk_payjoin_datasource.dart'; +import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; +import 'package:bb_mobile/core/payjoin/data/repository/payjoin_repository_impl.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/seed/data/datasources/seed_datasource.dart'; +import 'package:bb_mobile/core/seed/data/models/seed_model.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; +import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:bb_mobile/core/storage/tables/wallet_metadata_table.dart'; +import 'package:bb_mobile/core/utils/result.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'; +import 'package:bb_mobile/core/wallet/data/models/wallet_model.dart'; +import 'package:bb_mobile/core/wallet/data/models/wallet_utxo_model.dart'; +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/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/wallet_metadata_service.dart'; +import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockLocalPayjoinDatasource extends Mock + implements LocalPayjoinDatasource {} + +class _MockPdkPayjoinDatasource extends Mock implements PdkPayjoinDatasource {} + +class _MockWalletMetadataDatasource extends Mock + implements WalletMetadataDatasource {} + +class _MockSeedDatasource extends Mock implements SeedDatasource {} + +class _MockBdkWalletDatasource extends Mock implements BdkWalletDatasource {} + +class _MockBdkBitcoinBlockchainDatasource extends Mock + implements BdkBitcoinBlockchainDatasource {} + +class _MockElectrumServersPort extends Mock implements ElectrumServersPort {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockWalletRepository extends Mock implements WalletRepository {} + +class _MockWalletTransactionRepository extends Mock + implements WalletTransactionRepository {} + +class _MockLabelsFacade extends Mock implements LabelsFacade {} + +class _MockLabel extends Mock implements Label {} + +class _FakeNewLabel extends Fake implements NewLabel {} + +// --------------------------------------------------------------------------- +// Fixture helpers (adapted from the payjoin-hardening test suite to the +// work-tree PayjoinModel / Wallet / WalletTransaction constructors). +// --------------------------------------------------------------------------- + +PayjoinReceiverModel _receiverModel({ + String id = 'pj1', + String walletId = 'w1', + String? originalTxId = 'orig-txid', + String? proposalPsbt, + String? txId, + int? amountSat, + // Defaults to already-elapsed (createdAt: 0) so isExpiryTimePassed is true + // by default. Pass a large value to get a not-yet-expired model instead + // (e.g. to exercise resume's live-session branches). + int expireAfterSec = 300, +}) { + return PayjoinModel.receiver( + id: id, + address: 'tb1qtest', + isTestnet: true, + receiver: '[]', + walletId: walletId, + pjUri: 'bitcoin:tb1qtest?pj=https://payjo.in', + maxFeeRateSatPerVb: BigInt.from(10), + createdAt: 0, + expireAfterSec: expireAfterSec, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + originalTxId: originalTxId, + proposalPsbt: proposalPsbt, + txId: txId, + amountSat: amountSat, + ) + as PayjoinReceiverModel; +} + +PayjoinSenderModel _senderModel({ + String uri = 'bitcoin:tb1qsender?pj=https://payjo.in', + String walletId = 'w1', + String originalTxId = 'sender-orig-txid', + String? proposalPsbt, +}) { + return PayjoinModel.sender( + uri: uri, + isTestnet: true, + sender: '[]', + walletId: walletId, + originalPsbt: 'cHNidP8=', + originalTxId: originalTxId, + amountSat: 50000, + createdAt: 0, + expireAfterSec: 300, + proposalPsbt: proposalPsbt, + ) + as PayjoinSenderModel; +} + +SettingsEntity _testSettings({int payjoinMinAmountSat = 10000}) => + SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + payjoinMinAmountSat: payjoinMinAmountSat, + ); + +Wallet _testWallet({String origin = 'w1'}) => Wallet( + origin: origin, + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.local, + signerDevice: null, + balanceSat: BigInt.zero, +); + +WalletTransaction _testWalletTx({ + required String txId, + required String walletId, +}) => WalletTransaction( + walletId: walletId, + network: Network.bitcoinMainnet, + direction: WalletTransactionDirection.incoming, + status: WalletTransactionStatus.confirmed, + txId: txId, + amountSat: 50000, + feeSat: 500, + vsize: 150, + inputs: const [], + outputs: const [], + isRbf: false, +); + +void main() { + late _MockLocalPayjoinDatasource localDatasource; + late _MockPdkPayjoinDatasource pdkDatasource; + late _MockWalletMetadataDatasource walletMetadataDatasource; + late _MockSeedDatasource seedDatasource; + late _MockBdkWalletDatasource bdkWalletDatasource; + late _MockBdkBitcoinBlockchainDatasource blockchainDatasource; + late _MockElectrumServersPort serversPort; + late _MockSettingsRepository settingsRepository; + late _MockWalletRepository walletRepository; + late _MockWalletTransactionRepository walletTransactionRepository; + late _MockLabelsFacade labelsFacade; + + late StreamController requestsController; + late StreamController proposalsController; + late StreamController expiredController; + + setUpAll(() { + registerFallbackValue(ElectrumServerNetwork.bitcoinTestnet); + registerFallbackValue(_FakeNewLabel()); + // PayjoinModel is sealed and can't be Fake-implemented from outside its + // library — register a real (throwaway) instance instead. + registerFallbackValue(_receiverModel()); + // Fallback for any() matchers against BdkWalletDatasource.signPsbt's + // `wallet` param in the real-signing-stack tests. + registerFallbackValue( + WalletModel.privateBdk( + id: 'w1', + scriptType: ScriptType.bip84, + mnemonic: 'abandon', + isTestnet: true, + ) + as PrivateBdkWalletModel, + ); + }); + + setUp(() { + localDatasource = _MockLocalPayjoinDatasource(); + pdkDatasource = _MockPdkPayjoinDatasource(); + walletMetadataDatasource = _MockWalletMetadataDatasource(); + seedDatasource = _MockSeedDatasource(); + bdkWalletDatasource = _MockBdkWalletDatasource(); + blockchainDatasource = _MockBdkBitcoinBlockchainDatasource(); + serversPort = _MockElectrumServersPort(); + settingsRepository = _MockSettingsRepository(); + walletRepository = _MockWalletRepository(); + walletTransactionRepository = _MockWalletTransactionRepository(); + labelsFacade = _MockLabelsFacade(); + + requestsController = StreamController.broadcast(); + proposalsController = StreamController.broadcast(); + expiredController = StreamController.broadcast(); + + when( + () => pdkDatasource.requestsForReceivers, + ).thenAnswer((_) => requestsController.stream); + when( + () => pdkDatasource.proposalsForSenders, + ).thenAnswer((_) => proposalsController.stream); + when( + () => pdkDatasource.expiredPayjoins, + ).thenAnswer((_) => expiredController.stream); + // dispose()/stopPolling are exercised by the repository's own teardown. + when(() => pdkDatasource.dispose()).thenAnswer((_) async {}); + when(() => pdkDatasource.stopPolling(any())).thenReturn(null); + + when( + () => localDatasource.fetchAll( + onlyUnfinished: any(named: 'onlyUnfinished'), + ), + ).thenAnswer((_) async => []); + // The resume sweep for expired-with-failed-fallback receivers/senders — + // empty by default, overridden in the sweep tests. Only runs from + // resumePayjoinsOnStartup, never the constructor. + when(() => localDatasource.fetchReceivers()).thenAnswer((_) async => []); + when(() => localDatasource.fetchSenders()).thenAnswer((_) async => []); + when(() => localDatasource.update(any())).thenAnswer((_) async {}); + + // Passive watchers must never fire spuriously: an empty sync stream and + // a not-found transaction lookup keep _watchForFallback/_watchForBroadcast + // arming a harmless no-op. + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => const Stream.empty()); + when( + () => walletRepository.getWallet(any(), sync: any(named: 'sync')), + ).thenAnswer((_) async => null); + when( + () => walletTransactionRepository.getWalletTransaction( + any(), + walletId: any(named: 'walletId'), + sync: any(named: 'sync'), + ), + ).thenAnswer((_) async => null); + }); + + tearDown(() async { + await requestsController.close(); + await proposalsController.close(); + await expiredController.close(); + }); + + PayjoinRepositoryImpl buildRepository() => + PayjoinRepositoryImpl( + localPayjoinDatasource: localDatasource, + pdkPayjoinDatasource: pdkDatasource, + walletMetadataDatasource: walletMetadataDatasource, + seedDatasource: seedDatasource, + bdkWalletDatasource: bdkWalletDatasource, + blockchainDatasource: blockchainDatasource, + serversPort: serversPort, + walletRepository: () => walletRepository, + walletTransactionRepository: () => walletTransactionRepository, + settingsRepository: settingsRepository, + labelsFacade: () => labelsFacade, + ) + // Zero the fallback-retry delay: with the real 1s delay a permanently + // failing broadcast's later attempts fire (on stale mocks) during + // subsequent tests. No test depends on the delay's duration. + ..fallbackRetryDelay = Duration.zero; + + PayjoinReceiverModel buildReceiverModel({ + required int amountSat, + bool isExpired = false, + }) => + PayjoinModel.receiver( + id: 'r1', + address: 'bcrt1qaddress', + isTestnet: true, + receiver: '[]', + walletId: 'w1', + pjUri: 'bitcoin:bcrt1qaddress?pj=https://payjo.in/x', + maxFeeRateSatPerVb: BigInt.from(10000), + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + expireAfterSec: 86400, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + originalTxId: 'a' * 64, + amountSat: amountSat, + isExpired: isExpired, + ) + as PayjoinReceiverModel; + + group('below-minimum decline', () { + test( + 'declines via the PDK cancel path and never proposes a payjoin — ' + 'pinned so an inverted or removed threshold check cannot pass ' + 'silently (the below-minimum-decline test on the branch this was ' + 'adapted from could not fail before this assertion was added)', + () async { + when(() => settingsRepository.fetch()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.testnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: true, + payjoinMinAmountSat: 100000, + ), + ); + when( + () => pdkDatasource.declineReceiverSession(any()), + ).thenReturn('["cancelled"]'); + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when(() => localDatasource.fetchReceiver(any())).thenAnswer( + (_) async => buildReceiverModel( + amountSat: 1000, + ).copyWith(receiver: '["cancelled"]'), + ); + + final repository = buildRepository(); + addTearDown(repository.dispose); + // Below the 100,000 sat threshold configured above. + requestsController.add(buildReceiverModel(amountSat: 1000)); + await pumpEventQueue(); + + verify(() => pdkDatasource.declineReceiverSession(any())).called(1); + verify( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).called(1); + verifyNever( + () => pdkDatasource.proposePayjoin( + receiverModel: any(named: 'receiverModel'), + hasOwnedInputs: any(named: 'hasOwnedInputs'), + hasReceiverOutput: any(named: 'hasReceiverOutput'), + inputPairs: any(named: 'inputPairs'), + processPsbt: any(named: 'processPsbt'), + ), + ); + // walletMetadataDatasource.fetch is only reached by _loadWallet, + // which the propose path (never taken here) calls first — an + // indirect but sufficient signal that _proposePayjoin's whole + // chain, not just proposePayjoin itself, was skipped. + verifyNever(() => walletMetadataDatasource.fetch(any())); + }, + ); + + test('a request at or above the minimum is not declined', () async { + when(() => settingsRepository.fetch()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.testnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: true, + payjoinMinAmountSat: 10000, + ), + ); + // Let _proposePayjoin fail fast with a benign error instead of fully + // wiring the wallet/UTXO chain — this test only cares that the + // decline path (declineReceiverSession) is NOT taken above the + // threshold. + when( + () => walletMetadataDatasource.fetch(any()), + ).thenAnswer((_) async => null); + + final repository = buildRepository(); + addTearDown(repository.dispose); + requestsController.add(buildReceiverModel(amountSat: 10000)); + await pumpEventQueue(); + + verifyNever(() => pdkDatasource.declineReceiverSession(any())); + // Positive signal that the PROPOSE path (not just "no decline") was + // taken: loading the wallet is its first step. + verify(() => walletMetadataDatasource.fetch(any())).called(1); + }); + }); + + group('resume sweep for expired receivers with a failed fallback', () { + // The sweep now lives inside resumePayjoinsOnStartup (no longer fired + // from the constructor), so every test here calls it explicitly. + test('an expired-but-not-aborted receiver still holding the original tx ' + 'gets a broadcast attempt at startup and is marked aborted on ' + 'success — otherwise the sender payment is stranded forever ' + '(excluded from every onlyUnfinished resume)', () async { + final stranded = buildReceiverModel(amountSat: 5000, isExpired: true); + when( + () => localDatasource.fetchReceivers(), + ).thenAnswer((_) async => [stranded]); + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => localDatasource.fetchReceiver(any()), + ).thenAnswer((_) async => stranded); + + final repository = buildRepository(); + addTearDown(repository.dispose); + await repository.resumePayjoinsOnStartup(); + await pumpEventQueue(); + + verify( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).called(1); + final updated = verify( + () => localDatasource.update(captureAny()), + ).captured; + expect( + updated.whereType().any((m) => m.isAborted), + isTrue, + ); + }); + + test('an already-aborted expired receiver is left alone', () async { + final resolved = buildReceiverModel( + amountSat: 5000, + isExpired: true, + ).copyWith(isAborted: true); + when( + () => localDatasource.fetchReceivers(), + ).thenAnswer((_) async => [resolved]); + + final repository = buildRepository(); + addTearDown(repository.dispose); + await repository.resumePayjoinsOnStartup(); + await pumpEventQueue(); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + }); + }); + + group('isBelowPayjoinMinimum', () { + test('below the threshold', () { + expect( + PayjoinRepositoryImpl.isBelowPayjoinMinimum( + amountSat: 999, + minAmountSat: 1000, + ), + isTrue, + ); + }); + + test('at the threshold is not below it', () { + expect( + PayjoinRepositoryImpl.isBelowPayjoinMinimum( + amountSat: 1000, + minAmountSat: 1000, + ), + isFalse, + ); + }); + + test('null amount is never below the threshold', () { + expect( + PayjoinRepositoryImpl.isBelowPayjoinMinimum( + amountSat: null, + minAmountSat: 1000, + ), + isFalse, + ); + }); + }); + + group('labelCompletedPayjoinSend', () { + // Exercised directly rather than through the full reactive sender + // pipeline: that needs a real, valid PSBT for BitcoinTx.fromPsbt to + // parse (FFI-backed), which — like the existing payjoin datasource + // tests document — isn't practical to construct offline. + const txId = + 'b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90'; + + test( + 'stores a transaction label tagged with the payjoin system label', + () async { + when(() => labelsFacade.store(any())).thenAnswer( + (_) async => Ok( + Label( + id: 1, + type: LabelType.transaction, + label: LabelSystem.payjoin.label, + reference: txId, + ), + ), + ); + + final repository = buildRepository(); + addTearDown(repository.dispose); + await repository.labelCompletedPayjoinSend(txId); + + final captured = + verify(() => labelsFacade.store(captureAny())).captured.single + as NewLabel; + expect(captured.type, LabelType.transaction); + expect(captured.reference, txId); + expect(captured.label, LabelSystem.payjoin.label); + }, + ); + + test('swallows a store failure instead of throwing', () async { + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => const Err(LabelUnexpectedFailure('boom'))); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + await expectLater(repository.labelCompletedPayjoinSend(txId), completes); + }); + + test('swallows a thrown exception instead of propagating it', () async { + when(() => labelsFacade.store(any())).thenThrow(Exception('boom')); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + await expectLater(repository.labelCompletedPayjoinSend(txId), completes); + }); + }); + + // ------------------------------------------------------------------------- + // Ported (and adapted) from payjoin-hardening. DESIGN DIVERGENCE: hardening + // derived a fallback completion via `isCompleted`; the work tree has an + // explicit `isAborted` flag, so fallback outcomes are asserted via + // `isAborted` / `status == PayjoinStatus.aborted`. Real payjoin completion + // stays `isCompleted` / `status == completed`. + // ------------------------------------------------------------------------- + + group('tryBroadcastOriginalTransaction manual-call idempotency guard ' + '(the public entry point only — internal fallback callers bypass it ' + 'via _broadcastOriginalTransaction)', () { + setUp(() { + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('refuses a receiver already completed, and returns its current ' + 'state instead of re-broadcasting (+ isAborted sibling)', () async { + final model = _receiverModel( + originalTxId: 'orig-txid', + ).copyWith(isCompleted: true, txId: null); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + final result = await repository.tryBroadcastOriginalTransaction( + model.toEntity(), + ); + + expect(result, isNotNull); + expect(result!.isCompleted, isTrue); + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + + // isAborted sibling: a receiver already resolved via the fallback is + // likewise refused. + final aborted = _receiverModel( + originalTxId: 'orig-txid', + ).copyWith(isAborted: true, txId: null); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => aborted); + + final abortedResult = await repository.tryBroadcastOriginalTransaction( + aborted.toEntity(), + ); + + expect(abortedResult, isNotNull); + expect(abortedResult!.isAborted, isTrue); + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + }); + + test('refuses a receiver whose proposal was sent (proposed, not yet ' + 'completed) — the sender owns it for as long as that takes, with ' + 'no dead-end that would ever need a manual retry', () async { + final model = _receiverModel( + originalTxId: 'orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + await repository.tryBroadcastOriginalTransaction(model.toEntity()); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + }); + + test('refuses a sender already completed via a real payjoin, and does ' + 'NOT race it with the lower-fee original', () async { + final model = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ).copyWith(isCompleted: true, txId: 'real-payjoin-txid'); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + final result = await repository.tryBroadcastOriginalTransaction( + model.toEntity(), + ); + + expect(result, isNotNull); + expect((result! as PayjoinSender).txId, 'real-payjoin-txid'); + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + }); + + test('refuses a sender whose proposal is still being processed ' + '(proposalPsbt set, not yet completed or expired)', () async { + final model = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + await repository.tryBroadcastOriginalTransaction(model.toEntity()); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + }); + + test( + 'allows a sender manual retry once its OWN internal fallback also ' + 'gave up (isExpired, proposalPsbt still set) — no dead-end left, so ' + 'this must not be permanently blocked; result is terminal (aborted)', + () async { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + final model = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ).copyWith(isExpired: true); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + final result = await repository.tryBroadcastOriginalTransaction( + model.toEntity(), + ); + + verify( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).called(1); + expect(result, isNotNull); + expect(result!.isAborted, isTrue); + }, + ); + + test( + 'allows a receiver or sender manual retry while no proposal has ' + 'ever been received (waiting) — result is terminal (aborted)', + () async { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + final receiverModel = _receiverModel(originalTxId: 'orig-txid'); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => receiverModel); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + final result = await repository.tryBroadcastOriginalTransaction( + receiverModel.toEntity(), + ); + + verify( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).called(1); + expect(result, isNotNull); + expect(result!.isAborted, isTrue); + }, + ); + }); + + group('syncs the wallet after WE broadcast a transaction', () { + // Without this, the wallet balance/tx list only picked up a broadcast + // this repository itself just made once some unrelated sync happened to + // run — the same staleness class of gap _watchForBroadcast's active poll + // fixes for the receiver's own detection of the SENDER's broadcast. + setUp(() { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('tryBroadcastOriginalTransaction (receiver fallback) forces a ' + 'synced wallet lookup after a successful broadcast', () async { + final model = _receiverModel(walletId: 'w1', originalTxId: 'orig-txid'); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + await repository.tryBroadcastOriginalTransaction(model.toEntity()); + // The sync is deliberately fire-and-forget (unawaited); give its + // Future(() => ...) wrapper a tick to run before verifying. + await Future.delayed(Duration.zero); + + verify(() => walletRepository.getWallet('w1', sync: true)).called(1); + }); + + test('tryBroadcastOriginalTransaction (sender fallback) forces a synced ' + 'wallet lookup after a successful broadcast', () async { + final model = _senderModel( + walletId: 'w1', + originalTxId: 'sender-orig-txid', + ); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + await repository.tryBroadcastOriginalTransaction(model.toEntity()); + await Future.delayed(Duration.zero); + + verify(() => walletRepository.getWallet('w1', sync: true)).called(1); + }); + + test('a sync failure is swallowed and does not affect the already-' + 'successful broadcast result', () async { + when( + () => walletRepository.getWallet(any(), sync: any(named: 'sync')), + ).thenThrow(Exception('no network')); + final model = _receiverModel(walletId: 'w1', originalTxId: 'orig-txid'); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + + final result = await repository.tryBroadcastOriginalTransaction( + model.toEntity(), + ); + await Future.delayed(Duration.zero); + + expect(result, isNotNull); + expect(result!.isAborted, isTrue); + }); + + // SKIP: the _broadcastPsbt real-payjoin sync test. The work tree's + // _processPayjoinProposal re-derives the label txid via + // BitcoinTx.fromPsbt('signed-psbt'), which throws under FFI in a unit + // context and routes into the fallback path — the getWallet(sync: true) + // call inside _broadcastPsbt is not cleanly isolatable from the fallback + // path's own sync. The two fallback-side sync tests above cover the same + // _syncWalletAfterBroadcast machinery. + }); + + group('_processExpiredPayjoin sender terminal emission (#2246)', () { + // These drive the expiredPayjoins stream directly to exercise the + // repository's terminal-emission semantics on the send flow. + setUp(() { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('emits only the aborted result (no interim expired) when the ' + 'fallback broadcast succeeds', () async { + final model = _senderModel(originalTxId: 'sender-orig-txid'); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(model.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + // Exactly one terminal event, and it is the fallback (aborted) result — + // not the interim expired one that would race the success on the send + // flow. + expect(emitted, hasLength(1)); + expect(emitted.single.isAborted, isTrue); + await sub.cancel(); + }); + + test('emits the expired entity when the fallback broadcast fails so the ' + 'send flow does not hang', () async { + final model = _senderModel(originalTxId: 'sender-orig-txid'); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + // Make the broadcast fail -> the fallback returns null. + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenThrow(Exception('broadcast failed')); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(model.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + // A terminal expired event is still emitted so listeners aren't left + // hanging on "coordinating". + expect(emitted, hasLength(1)); + expect(emitted.single.isExpired, isTrue); + await sub.cancel(); + }); + + test('bails out when the persisted row already completed — an expiry ' + 'firing after a fallback resolution must not re-broadcast the ' + 'original transaction', () async { + // The poll's own stale copy says unfinished, but the row has since + // been completed by _onOriginalTransactionSeen (the counterparty's + // fallback broadcast landed on-chain). + final staleCopy = _senderModel(originalTxId: 'sender-orig-txid'); + final completedRow = staleCopy.copyWith(isCompleted: true, txId: null); + when( + () => localDatasource.fetchSender(staleCopy.uri), + ).thenAnswer((_) async => completedRow); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(staleCopy.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + verifyNever(() => localDatasource.update(any())); + expect(emitted, isEmpty); + await sub.cancel(); + }); + + test('bails out when the persisted row is already aborted — the ' + 'isAborted sibling of the completed-bail guard', () async { + final staleCopy = _senderModel(originalTxId: 'sender-orig-txid'); + final abortedRow = staleCopy.copyWith(isAborted: true, txId: null); + when( + () => localDatasource.fetchSender(staleCopy.uri), + ).thenAnswer((_) async => abortedRow); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(staleCopy.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + verifyNever(() => localDatasource.update(any())); + expect(emitted, isEmpty); + await sub.cancel(); + }); + + test('bails out when the session row no longer exists', () async { + final staleCopy = _senderModel(originalTxId: 'sender-orig-txid'); + when( + () => localDatasource.fetchSender(staleCopy.uri), + ).thenAnswer((_) async => null); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(staleCopy.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + expect(emitted, isEmpty); + await sub.cancel(); + }); + + test( + 'decides on the persisted row, not the stale event copy: a ' + 'proposal persisted since the copy was captured suppresses the ' + 'original-transaction fallback and keeps the proposal intact', + () async { + final staleCopy = _senderModel(originalTxId: 'sender-orig-txid'); + final freshRow = staleCopy.copyWith( + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchSender(staleCopy.uri), + ).thenAnswer((_) async => freshRow); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + expiredController.add(staleCopy.copyWith(isExpired: true)); + await Future.delayed(Duration.zero); + + // Once a proposal is out the sender owns broadcasting the payjoin + // transaction — no original fallback. + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + // The expired marker is persisted on the FRESH row: persisting the + // stale copy would clobber the proposal (insertOnConflictUpdate + // replaces the whole row). + final persisted = + verify(() => localDatasource.update(captureAny())).captured.single + as PayjoinSenderModel; + expect(persisted.proposalPsbt, 'cHNidP9wcm9wb3NhbA=='); + expect(persisted.isExpired, isTrue); + expect(emitted.single.isExpired, isTrue); + await sub.cancel(); + }, + ); + }); + + group('_processPayjoinProposal terminal emission on broadcast failure ' + '(#2246)', () { + // A received proposal whose signing/broadcast fails must still produce a + // terminal event, because by the time a proposal arrives the poll timer + // that would otherwise raise an expiry is already cancelled — nothing + // else will ever emit for this session again. + setUp(() { + // _loadWallet throws when metadata is missing — the simplest way to + // drive _processPayjoinProposal's catch without mocking a full signing + // stack. + when( + () => walletMetadataDatasource.fetch(any()), + ).thenAnswer((_) async => null); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('falls back to broadcasting the original psbt and completes ' + '(aborted) when signing/broadcasting the proposal fails', () async { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + final model = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + proposalsController.add(model); + await Future.delayed(Duration.zero); + + // Two events: the raw "proposal received" one, then the fallback's + // terminal aborted one (the original transaction still got broadcast, + // so the send flow can resolve to success). + expect(emitted, hasLength(2)); + expect(emitted.last.isAborted, isTrue); + await sub.cancel(); + }); + + test( + 'marks the session terminally failed (expired) when both the ' + 'proposal and the original-transaction fallback fail to broadcast', + () async { + final model = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchSender(model.uri), + ).thenAnswer((_) async => model); + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenThrow(Exception('broadcast failed')); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + proposalsController.add(model); + await Future.delayed(Duration.zero); + + // Terminal failure, not silence: the send flow must never hang forever + // waiting for an event that will never arrive. + expect(emitted, hasLength(2)); + expect(emitted.last.isExpired, isTrue); + await sub.cancel(); + }, + ); + + test('bails out when the persisted session already aborted — a proposal ' + 'event arriving after the counterparty fell back must not resurrect ' + 'the row nor sign/broadcast', () async { + // The proposal event carries the datasource's stale in-memory copy, but + // the persisted row was aborted since (the fallback watcher saw the + // original transaction land on-chain). + final staleCopy = _senderModel( + originalTxId: 'sender-orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + final abortedRow = staleCopy.copyWith(isAborted: true, txId: null); + when( + () => localDatasource.fetchSender(staleCopy.uri), + ).thenAnswer((_) async => abortedRow); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + proposalsController.add(staleCopy); + await Future.delayed(Duration.zero); + + // No sign/broadcast, no persist, no emission: the session already + // resolved another way. + verifyNever( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ); + verifyNever(() => localDatasource.update(any())); + expect(emitted, isEmpty); + await sub.cancel(); + }); + }); + + group('resumePayjoinsOnStartup', () { + test( + 'one session failing to resume does not stop the others from resuming', + () async { + // Both models are already past expiry by construction (createdAt: 0, + // expireAfterSec: 300), and both have a proposal already sent, so + // _resumeOne routes them straight to _processExpiredPayjoin's + // persist-and-emit else branch (no broadcast attempted). + final bad = _receiverModel( + id: 'bad', + originalTxId: 'bad-orig', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + final ok = _receiverModel( + id: 'ok', + originalTxId: 'ok-orig', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [bad, ok]); + // _processExpiredPayjoin re-fetches the persisted row before acting + // (stale-copy guard) — serve each session's own row back. + when( + () => localDatasource.fetchReceiver('bad'), + ).thenAnswer((_) async => bad); + when( + () => localDatasource.fetchReceiver('ok'), + ).thenAnswer((_) async => ok); + // "bad"'s persist throws (e.g. a transient DB failure); "ok"'s must + // still go through despite "bad" throwing first in the loop. + when( + () => localDatasource.update( + any(that: predicate((m) => m.id == 'bad')), + ), + ).thenThrow(Exception('boom')); + when( + () => localDatasource.update( + any(that: predicate((m) => m.id == 'ok')), + ), + ).thenAnswer((_) async {}); + + final repository = buildRepository(); + addTearDown(repository.dispose); + final emitted = []; + final sub = repository.payjoinStream.listen(emitted.add); + + await repository.resumePayjoinsOnStartup(); + await Future.delayed(Duration.zero); + + // "ok" was still resumed and emitted, proving the per-session + // try/catch stopped "bad"'s failure from aborting the whole loop. + expect(emitted, hasLength(1)); + expect(emitted.single.id, 'ok'); + await sub.cancel(); + }, + ); + }); + + group('_watchForBroadcast active polling', () { + // The passive watcher only reacts to walletSyncFinishedStream, i.e. to + // syncs triggered by something else entirely. The active poll forces + // bounded sync'd lookups itself. + late StreamController syncController; + + setUp(() { + syncController = StreamController.broadcast(); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + + // A not-yet-expired proposal-sent receiver session, so resume arms + // _watchForBroadcast. + final model = _receiverModel( + id: 'pj1', + walletId: 'w1', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + expireAfterSec: 9999999999, + ); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + }); + + tearDown(() => syncController.close()); + + test('completes the session via a forced-sync lookup when no wallet sync ' + 'ever happens, then stops polling', () { + fakeAsync((async) { + var txSeen = false; + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer( + (_) async => txSeen + ? _testWalletTx(txId: 'payjoin-txid', walletId: 'w1') + : null, + ); + + final repo = buildRepository(); + unawaited(repo.resumePayjoinsOnStartup()); + async.flushMicrotasks(); + + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + // First poll fires after the initial delay; the tx isn't visible yet. + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay); + expect(emitted, isEmpty); + + // The sender broadcasts; the next (backed-off) poll finds the tx and + // completes the session — no walletSyncFinishedStream event ever + // fired in this entire test. + txSeen = true; + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay * 2); + async.flushMicrotasks(); + + expect(emitted, hasLength(1)); + expect(emitted.single.isCompleted, isTrue); + verify( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).called(2); + + // Completion is one-shot: no further forced syncs afterwards. + async.elapse(const Duration(hours: 2)); + verifyNever( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ); + }); + }); + + test('gives up active polling after the attempt budget but the passive ' + 'sync-driven watcher still completes a very late broadcast', () { + fakeAsync((async) { + var txSeen = false; + // Active polls never see the tx (it lands hours later). + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer((_) async => null); + // Passive (local, non-forced) lookups see it once txSeen flips. + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + ), + ).thenAnswer( + (_) async => txSeen + ? _testWalletTx(txId: 'payjoin-txid', walletId: 'w1') + : null, + ); + + final repo = buildRepository(); + unawaited(repo.resumePayjoinsOnStartup()); + async.flushMicrotasks(); + + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + // Way past the whole active-poll schedule: exactly maxAttempts + // forced syncs ran, then the poll chain stopped rescheduling. + async.elapse(const Duration(hours: 3)); + verify( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).called(PayjoinRepositoryImpl.broadcastPollMaxAttempts); + expect(emitted, isEmpty); + + // The payjoin tx finally lands and some organic sync of this wallet + // finishes: the passive watcher completes the session. + txSeen = true; + syncController.add(_testWallet(origin: 'w1')); + async.flushMicrotasks(); + + expect(emitted, hasLength(1)); + expect(emitted.single.isCompleted, isTrue); + }); + }); + }); + + group('post-broadcast visibility watch (own fallback broadcast)', () { + // After WE broadcast the original transaction, the single unawaited + // wallet sync can be throttled or race the broadcast. The original-tx + // watch re-armed by _broadcastOriginalTransaction must keep forcing + // DIRECT sync'd lookups until the tx is visible, then tear itself down + // without emitting duplicate events. + late StreamController syncController; + + setUp(() { + syncController = StreamController.broadcast(); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + tearDown(() => syncController.close()); + + test('keeps forcing sync\'d lookups until the broadcast original is ' + 'visible in the wallet, then tears down', () { + fakeAsync((async) { + var txSeen = false; + when( + () => walletTransactionRepository.getWalletTransaction( + 'orig-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer( + (_) async => + txSeen ? _testWalletTx(txId: 'orig-txid', walletId: 'w1') : null, + ); + + // The row is re-fetched several times along the way (the manual + // guard, the broadcast itself, and the visibility watch's completion + // handler) — serve back whatever was last persisted so the watch + // sees the completed row once the broadcast stored it. + var row = _receiverModel( + id: 'pj1', + walletId: 'w1', + originalTxId: 'orig-txid', + expireAfterSec: 9999999999, + ); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => row); + when(() => localDatasource.update(any())).thenAnswer(( + invocation, + ) async { + row = invocation.positionalArguments.single as PayjoinReceiverModel; + }); + + final repo = buildRepository(); + + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + unawaited(repo.tryBroadcastOriginalTransaction(row.toEntity())); + async.flushMicrotasks(); + + // First poll: the wallet doesn't see the tx yet (throttled sync / + // raced broadcast). + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay); + + // The next backed-off poll finds it — no walletSyncFinishedStream + // event ever fired in this test, so only the forced lookups can + // have made it visible. + txSeen = true; + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay * 2); + async.flushMicrotasks(); + + verify( + () => walletTransactionRepository.getWalletTransaction( + 'orig-txid', + walletId: 'w1', + sync: true, + ), + ).called(2); + + // The public manual-broadcast entry point emits the aborted result + // exactly once (so other open watchers of this session learn of it); + // the re-armed visibility watch then resolves as a PURE TEARDOWN — + // no SECOND terminal event — because the session is already aborted. + expect(emitted, hasLength(1)); + expect(emitted.single.isAborted, isTrue); + async.elapse(const Duration(hours: 2)); + verifyNever( + () => walletTransactionRepository.getWalletTransaction( + 'orig-txid', + walletId: 'w1', + sync: true, + ), + ); + }); + }); + }); + + group('_watchForFallback (the counterparty fell back independently)', () { + // Both sides hold their own copy of the original transaction and can + // each independently decide to broadcast it. Before this watch existed, + // only the side that actually broadcast it persisted the terminal state + // — the OTHER side had no way to find out and just kept waiting on its + // own session. + setUp(() { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('a sender still waiting for a proposal is resolved (aborted) once ' + 'the original transaction appears in its wallet — the receiver ' + 'broadcast it independently and the sender would otherwise have ' + 'waited out its own full expiry with no signal', () async { + final syncController = StreamController.broadcast(); + addTearDown(syncController.close); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + + // Not yet expired, no proposal received yet: _resumeOne's sender branch + // arms _watchForFallback unconditionally. + final model = _senderModel( + originalTxId: 'sender-orig-txid', + ).copyWith(expireAfterSec: 9999999999); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + // _onOriginalTransactionSeen always tries the receiver table first. + when( + () => localDatasource.fetchReceiver( + 'bitcoin:tb1qsender?pj=https://payjo.in', + ), + ).thenAnswer((_) async => null); + when( + () => localDatasource.fetchSender( + 'bitcoin:tb1qsender?pj=https://payjo.in', + ), + ).thenAnswer((_) async => model); + + // The original transaction is now visible in the sender's own wallet — + // broadcast by the receiver, not by this device. + when( + () => walletTransactionRepository.getWalletTransaction( + 'sender-orig-txid', + walletId: 'w1', + ), + ).thenAnswer( + (_) async => _testWalletTx(txId: 'sender-orig-txid', walletId: 'w1'), + ); + + final repo = buildRepository(); + addTearDown(repo.dispose); + + await repo.resumePayjoinsOnStartup(); + + final emitted = []; + final sub = repo.payjoinStream.listen(emitted.add); + + syncController.add(_testWallet(origin: 'w1')); + await Future.delayed(Duration.zero); + + expect(emitted, hasLength(1)); + expect(emitted.single.status, PayjoinStatus.aborted); + expect(emitted.single.isAborted, isTrue); + expect((emitted.single as PayjoinSender).txId, isNull); + await sub.cancel(); + }); + + test('idempotent: a session already terminal (isAborted set by whichever ' + 'path got there first) is left untouched, not re-persisted or ' + 're-emitted', () async { + final syncController = StreamController.broadcast(); + addTearDown(syncController.close); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + + final model = _senderModel( + originalTxId: 'sender-orig-txid', + ).copyWith(expireAfterSec: 9999999999); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + when( + () => localDatasource.fetchReceiver( + 'bitcoin:tb1qsender?pj=https://payjo.in', + ), + ).thenAnswer((_) async => null); + // Already resolved (aborted) by the time the watch fires. + final alreadyAborted = model.copyWith(isAborted: true, txId: null); + when( + () => localDatasource.fetchSender( + 'bitcoin:tb1qsender?pj=https://payjo.in', + ), + ).thenAnswer((_) async => alreadyAborted); + when( + () => walletTransactionRepository.getWalletTransaction( + 'sender-orig-txid', + walletId: 'w1', + ), + ).thenAnswer( + (_) async => _testWalletTx(txId: 'sender-orig-txid', walletId: 'w1'), + ); + + final repo = buildRepository(); + addTearDown(repo.dispose); + + await repo.resumePayjoinsOnStartup(); + + final emitted = []; + final sub = repo.payjoinStream.listen(emitted.add); + + syncController.add(_testWallet(origin: 'w1')); + await Future.delayed(Duration.zero); + + expect(emitted, isEmpty); + verifyNever(() => localDatasource.update(any())); + await sub.cancel(); + }); + + test('a receiver session surviving a failed own-broadcast attempt still ' + 'resolves (aborted) once the original transaction is later observed ' + 'on-chain (the fix: _stopWatching is no longer called before ' + 'attempting the broadcast, so a failed attempt no longer strands the ' + 'session)', () async { + final syncController = StreamController.broadcast(); + addTearDown(syncController.close); + when( + () => settingsRepository.fetch(), + ).thenAnswer((_) async => _testSettings(payjoinMinAmountSat: 10000)); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + + final repo = buildRepository(); + addTearDown(repo.dispose); + + // Below minimum, so _processPayjoinRequest attempts the fallback + // broadcast immediately — but the broadcast itself fails. + final model = _receiverModel(originalTxId: 'orig-txid', amountSat: 500); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenThrow(Exception('no network')); + when( + () => walletTransactionRepository.getWalletTransaction( + 'orig-txid', + walletId: 'w1', + ), + ).thenAnswer((_) async => null); + + final emitted = []; + final sub = repo.payjoinStream.listen(emitted.add); + + requestsController.add(model); + await Future.delayed(Duration.zero); + + // Own attempt failed: _processPayjoinRequest's below-minimum branch + // only emits on success, so just the initial "requested" event is on + // the stream — still not resolved. + expect(emitted, hasLength(1)); + expect(emitted.single.isAborted, isFalse); + + // The original transaction eventually lands anyway. The fallback + // watch armed at the top of _processPayjoinRequest catches this: it + // survived the failed attempt because _stopWatching is no longer + // called before attempting the broadcast. + when( + () => walletTransactionRepository.getWalletTransaction( + 'orig-txid', + walletId: 'w1', + ), + ).thenAnswer( + (_) async => _testWalletTx(txId: 'orig-txid', walletId: 'w1'), + ); + syncController.add(_testWallet(origin: 'w1')); + await Future.delayed(Duration.zero); + + expect(emitted, hasLength(2)); + expect(emitted.last.status, PayjoinStatus.aborted); + await sub.cancel(); + }); + }); + + group('watcher arming (broadcast + fallback) on the request / resume / ' + 'expired-else paths', () { + // These pin that the reactive handlers ARM the broadcast/fallback + // watchers on the paths that must keep a live session detectable. Each + // proves the watch is live by making its target txid visible through a + // FORCED (sync: true) lookup and elapsing the active poll under + // fakeAsync — the same technique the sibling '_watchForBroadcast active + // polling' / '_watchForFallback' groups use — then asserting the + // terminal stream emission the watcher's onSeen handler produces. + setUp(() { + when( + () => serversPort.runWithFallback( + network: any(named: 'network'), + operation: any(named: 'operation'), + ), + ).thenAnswer((_) async {}); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + }); + + test('_processPayjoinRequest arms _watchForBroadcast after a successful ' + 'propose — the receiver session completes once the sender broadcasts ' + 'the payjoin transaction', () { + fakeAsync((async) { + // A request AT the minimum, so _processPayjoinRequest takes the + // propose path (not the below-minimum decline). + when( + () => settingsRepository.fetch(), + ).thenAnswer((_) async => _testSettings(payjoinMinAmountSat: 10000)); + + // _loadWallet's dependencies: a decodable-origin metadata row and a + // mnemonic seed. The mnemonic is only join()'d into a WalletModel + // that getUtxos (mocked) receives — never expanded to a seed here, + // so any placeholder words are fine. + final origin = WalletMetadataService.encodeOrigin( + fingerprint: '00000000', + network: Network.bitcoinTestnet, + scriptType: ScriptType.bip84, + ); + when(() => walletMetadataDatasource.fetch('w1')).thenAnswer( + (_) async => WalletMetadataModel( + id: origin, + masterFingerprint: '00000000', + xpubFingerprint: '00000000', + isEncryptedVaultTested: false, + isPhysicalBackupTested: false, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: Signer.local, + isDefault: false, + ), + ); + when(() => seedDatasource.get('00000000')).thenAnswer( + (_) async => + const SeedModel.mnemonic(mnemonicWords: ['abandon']) + as MnemonicSeedModel, + ); + + // One owned bitcoin utxo so _filterAvailableUtxos yields a non-empty + // input set and _proposePayjoin doesn't bail with NoInputs. + when( + () => bdkWalletDatasource.getUtxos(wallet: any(named: 'wallet')), + ).thenAnswer( + (_) async => [ + WalletUtxoModel.bitcoin( + txId: 'utxo-txid', + vout: 0, + amountSat: BigInt.from(100000), + scriptPubkey: Uint8List.fromList([0]), + address: 'tb1qtest', + isExternalKeyChain: false, + ) + as BitcoinWalletUtxoModel, + ], + ); + when( + () => bdkWalletDatasource.createIsMineChecker( + wallet: any(named: 'wallet'), + ), + ).thenAnswer( + (_) async => + (Uint8List _) => true, + ); + when( + () => bdkWalletDatasource.createPsbtSigner( + wallet: any(named: 'wallet'), + ), + ).thenAnswer( + (_) async => + (String psbt) => psbt, + ); + + final requestModel = _receiverModel( + id: 'pj1', + walletId: 'w1', + originalTxId: 'orig-txid', + amountSat: 10000, + expireAfterSec: 9999999999, + ); + // getUtxosFrozenByOngoingPayjoins reads fetchAll(onlyUnfinished) — + // kept empty (setUp default) so it never touches FFI BitcoinTx. + // _proposePayjoin re-fetches the receiver row before proposing. + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => requestModel); + // proposePayjoin (FFI-backed in production) is mocked to return a + // proposal-sent model carrying BOTH proposalPsbt and txId — the two + // fields _processPayjoinRequest gates the _watchForBroadcast arming + // on. + when( + () => pdkDatasource.proposePayjoin( + receiverModel: any(named: 'receiverModel'), + hasOwnedInputs: any(named: 'hasOwnedInputs'), + hasReceiverOutput: any(named: 'hasReceiverOutput'), + inputPairs: any(named: 'inputPairs'), + processPsbt: any(named: 'processPsbt'), + ), + ).thenAnswer( + (_) async => requestModel.copyWith( + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + ), + ); + + var txSeen = false; + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer( + (_) async => txSeen + ? _testWalletTx(txId: 'payjoin-txid', walletId: 'w1') + : null, + ); + // The completion handler re-fetches the receiver row. + when(() => localDatasource.fetchReceiver('pj1')).thenAnswer( + (_) async => requestModel.copyWith( + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + ), + ); + + final repo = buildRepository(); + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + requestsController.add(requestModel); + async.flushMicrotasks(); + + // The proposal-sent event is on the stream; the broadcast watcher is + // now armed. The sender broadcasts; the forced poll finds the tx and + // completes the session — no wallet-sync event ever fired. + txSeen = true; + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay); + async.flushMicrotasks(); + + expect( + emitted.any((p) => p.isCompleted), + isTrue, + reason: + 'the armed _watchForBroadcast should complete the session ' + 'once the payjoin txid becomes visible via a forced sync', + ); + }); + }); + + test('_resumeOne arms BOTH watchers for a receiver with a proposal ' + 'already sent — the broadcast watcher completes the session once the ' + 'payjoin transaction becomes visible', () { + fakeAsync((async) { + // Not expiry-passed, proposal already sent, txId + originalTxId set: + // _resumeOne's `model.txId != null` branch arms _watchForBroadcast + // (payjoin-txid) AND _watchForFallback (originalTxId). + final model = _receiverModel( + id: 'pj1', + walletId: 'w1', + originalTxId: 'orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + expireAfterSec: 9999999999, + ); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + var txSeen = false; + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer( + (_) async => txSeen + ? _testWalletTx(txId: 'payjoin-txid', walletId: 'w1') + : null, + ); + + final repo = buildRepository(); + unawaited(repo.resumePayjoinsOnStartup()); + async.flushMicrotasks(); + + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + // The broadcast watcher (armed by resume) fires the moment the + // payjoin tx becomes visible via a forced poll. + txSeen = true; + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay); + async.flushMicrotasks(); + + expect(emitted, hasLength(1)); + expect(emitted.single.isCompleted, isTrue); + }); + }); + + test('the expired-else branch re-arms _watchForBroadcast for a resumed, ' + 'expiry-passed receiver whose proposal was already sent — the ' + 'session still completes when the payjoin transaction lands, despite ' + 'having been marked expired', () { + fakeAsync((async) { + // Expiry-passed (createdAt: 0, small expireAfterSec) with a proposal + // already out and a payjoin txId: _resumeOne routes it through + // _processExpiredPayjoin, whose else branch persists the expired + // marker AND re-arms _watchForBroadcast(txId). + final model = _receiverModel( + id: 'pj1', + walletId: 'w1', + originalTxId: 'orig-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + expireAfterSec: 1, + ); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + // _processExpiredPayjoin re-fetches the persisted row (stale-copy + // guard); the completion handler re-fetches it too. + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + + var txSeen = false; + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer( + (_) async => txSeen + ? _testWalletTx(txId: 'payjoin-txid', walletId: 'w1') + : null, + ); + + final repo = buildRepository(); + unawaited(repo.resumePayjoinsOnStartup()); + async.flushMicrotasks(); + + final emitted = []; + repo.payjoinStream.listen(emitted.add); + + // The expired marker was persisted... + verify( + () => localDatasource.update( + any(that: predicate((m) => m.isExpired)), + ), + ).called(greaterThanOrEqualTo(1)); + + // ...but the re-armed broadcast watcher still completes the session + // once the payjoin transaction becomes visible. + txSeen = true; + async.elapse(PayjoinRepositoryImpl.broadcastPollInitialDelay); + async.flushMicrotasks(); + + expect( + emitted.any((p) => p.isCompleted), + isTrue, + reason: + 'the expired-else branch must re-arm _watchForBroadcast so ' + 'a proposal that lands after expiry still completes', + ); + }); + }); + }); + + group('payjoin labeling on the real completion paths', () { + // The payjoin system label must mean "this payment actually got + // CoinJoin-style privacy". Only RECEIVER labeling is ported here: the + // sender-side path needs FFI BitcoinTx.fromPsbt on a signed psbt, which + // isn't practical to construct in a unit test (the work tree keeps its + // own labelCompletedPayjoinSend group above for the sender label). + test('labels the receiver payjoin tx once it is seen on-chain ' + '(_onPayjoinTransactionSeen)', () async { + final syncController = StreamController.broadcast(); + addTearDown(syncController.close); + when( + () => walletRepository.walletSyncFinishedStream, + ).thenAnswer((_) => syncController.stream); + when( + () => labelsFacade.store(any()), + ).thenAnswer((_) async => Ok(_MockLabel())); + + final model = _receiverModel( + id: 'pj1', + walletId: 'w1', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + txId: 'payjoin-txid', + expireAfterSec: 9999999999, + ); + when( + () => localDatasource.fetchAll(onlyUnfinished: true), + ).thenAnswer((_) async => [model]); + when( + () => localDatasource.fetchReceiver('pj1'), + ).thenAnswer((_) async => model); + when( + () => walletTransactionRepository.getWalletTransaction( + 'payjoin-txid', + walletId: 'w1', + ), + ).thenAnswer( + (_) async => _testWalletTx(txId: 'payjoin-txid', walletId: 'w1'), + ); + + final repo = buildRepository(); + addTearDown(repo.dispose); + await repo.resumePayjoinsOnStartup(); + + syncController.add(_testWallet(origin: 'w1')); + await Future.delayed(Duration.zero); + + final stored = + verify(() => labelsFacade.store(captureAny())).captured.single + as NewLabel; + expect(stored.type, LabelType.transaction); + expect(stored.reference, 'payjoin-txid'); + expect(stored.label, LabelSystem.payjoin.label); + expect(stored.origin, 'w1'); + }); + }); +} diff --git a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart index 9703404cd4..3d77179fc0 100644 --- a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart +++ b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart @@ -2,7 +2,10 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:bb_mobile/core/payjoin/data/datasources/pdk_payjoin_datasource.dart'; +import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; +import 'package:bb_mobile/core/utils/constants.dart' show PayjoinConstants; import 'package:dio/dio.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:payjoin/payjoin.dart'; @@ -373,4 +376,89 @@ void main() { expect(result, Uint8List.fromList([4, 5, 6])); }); }); + + group('PdkPayjoinDatasource.stopPolling', () { + // A session whose expiry time is long passed (createdAt: 0): the first + // poll tick emits an expired event without any network access, which + // makes the poll's liveness observable offline. + PayjoinSenderModel expiredSenderModel() => + PayjoinModel.sender( + uri: 'bitcoin:tb1qsender?pj=https://payjo.in', + isTestnet: true, + sender: '[]', + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: 'orig-txid', + amountSat: 50000, + createdAt: 0, + expireAfterSec: 300, + ) + as PayjoinSenderModel; + + test('control: without stopPolling the poll raises the expiry', () { + fakeAsync((async) { + final datasource = PdkPayjoinDatasource(dio: Dio()); + final events = []; + final sub = datasource.expiredPayjoins.listen(events.add); + + datasource.startListeningForProposal(expiredSenderModel()); + async.elapse( + const Duration( + seconds: PayjoinConstants.directoryPollingInterval + 1, + ), + ); + + expect(events, hasLength(1)); + sub.cancel(); + datasource.dispose(); + async.flushMicrotasks(); + }); + }); + + test('cancels the session poll so no further event ever fires — the ' + 'repository calls this when a session resolves through a path the ' + 'poll cannot see (fallback landed on-chain)', () { + fakeAsync((async) { + final datasource = PdkPayjoinDatasource(dio: Dio()); + final events = []; + final sub = datasource.expiredPayjoins.listen(events.add); + + final model = expiredSenderModel(); + datasource.startListeningForProposal(model); + datasource.stopPolling(model.id); + async.elapse( + const Duration( + seconds: PayjoinConstants.directoryPollingInterval * 3, + ), + ); + + expect(events, isEmpty); + sub.cancel(); + datasource.dispose(); + async.flushMicrotasks(); + }); + }); + }); + + group('PdkPayjoinDatasource.dispose', () { + test('closes the event streams', () async { + final datasource = PdkPayjoinDatasource(dio: Dio()); + + await datasource.dispose(); + + // A closed broadcast stream completes immediately with no events. + await expectLater(datasource.requestsForReceivers, emitsDone); + await expectLater(datasource.proposalsForSenders, emitsDone); + await expectLater(datasource.expiredPayjoins, emitsDone); + }); + + test('is idempotent (a second dispose is a no-op)', () async { + final datasource = PdkPayjoinDatasource(dio: Dio()); + + await datasource.dispose(); + // Without the _disposed guard this would throw on re-closing a closed + // controller. + await expectLater(datasource.dispose(), completes); + }); + }); } diff --git a/test/core_test/payjoin/receive_with_payjoin_usecase_test.dart b/test/core_test/payjoin/receive_with_payjoin_usecase_test.dart new file mode 100644 index 0000000000..b1f472ba00 --- /dev/null +++ b/test/core_test/payjoin/receive_with_payjoin_usecase_test.dart @@ -0,0 +1,93 @@ +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/settings/data/settings_repository.dart'; +import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockPayjoinRepository extends Mock implements PayjoinRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +void main() { + setUpAll(() => registerFallbackValue(BigInt.zero)); + + late _MockPayjoinRepository payjoinRepository; + late _MockSettingsRepository settingsRepository; + late ReceiveWithPayjoinUsecase usecase; + + final receiver = + Payjoin.receiver( + id: 'r1', + isTestnet: true, + walletId: 'w1', + pjUri: 'bitcoin:tb1qtest?pj=https://payjo.in/x', + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 2), + ) + as PayjoinReceiver; + + setUp(() { + payjoinRepository = _MockPayjoinRepository(); + settingsRepository = _MockSettingsRepository(); + usecase = ReceiveWithPayjoinUsecase( + payjoinRepository: payjoinRepository, + settingsRepository: settingsRepository, + ); + + when(() => settingsRepository.fetch()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.testnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: true, + payjoinExpireAfterSec: 3600, + ), + ); + when( + () => payjoinRepository.createPayjoinReceiver( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + isTestnet: any(named: 'isTestnet'), + maxFeeRateSatPerVb: any(named: 'maxFeeRateSatPerVb'), + expireAfterSec: any(named: 'expireAfterSec'), + ), + ).thenAnswer((_) async => receiver); + }); + + test( + 'creates the session with the user-configured expiry from settings', + () async { + await usecase.execute(walletId: 'w1', address: 'tb1qtest'); + + verify( + () => payjoinRepository.createPayjoinReceiver( + walletId: 'w1', + address: 'tb1qtest', + isTestnet: true, + maxFeeRateSatPerVb: any(named: 'maxFeeRateSatPerVb'), + expireAfterSec: 3600, + ), + ).called(1); + }, + ); + + test('an explicit expiry override wins over the settings value', () async { + await usecase.execute( + walletId: 'w1', + address: 'tb1qtest', + expireAfterSec: 120, + ); + + verify( + () => payjoinRepository.createPayjoinReceiver( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + isTestnet: any(named: 'isTestnet'), + maxFeeRateSatPerVb: any(named: 'maxFeeRateSatPerVb'), + expireAfterSec: 120, + ), + ).called(1); + }); +} diff --git a/test/core_test/payjoin/send_with_payjoin_usecase_test.dart b/test/core_test/payjoin/send_with_payjoin_usecase_test.dart new file mode 100644 index 0000000000..b5a94993f2 --- /dev/null +++ b/test/core_test/payjoin/send_with_payjoin_usecase_test.dart @@ -0,0 +1,117 @@ +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/send_with_payjoin_usecase.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; +import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:bb_mobile/core/wallet/data/repositories/bitcoin_wallet_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockPayjoinRepository extends Mock implements PayjoinRepository {} + +class _MockBitcoinWalletRepository extends Mock + implements BitcoinWalletRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +void main() { + late _MockPayjoinRepository payjoinRepository; + late _MockBitcoinWalletRepository bitcoinWalletRepository; + late _MockSettingsRepository settingsRepository; + late SendWithPayjoinUsecase usecase; + + final sender = + Payjoin.sender( + uri: 'bitcoin:tb1qtest?pj=https://payjo.in/x', + isTestnet: true, + walletId: 'w1', + originalPsbt: 'signed-psbt', + originalTxId: 'a' * 64, + amountSat: 10000, + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 2), + ) + as PayjoinSender; + + setUp(() { + payjoinRepository = _MockPayjoinRepository(); + bitcoinWalletRepository = _MockBitcoinWalletRepository(); + settingsRepository = _MockSettingsRepository(); + usecase = SendWithPayjoinUsecase( + payjoinRepository: payjoinRepository, + bitcoinWalletRepository: bitcoinWalletRepository, + settingsRepository: settingsRepository, + ); + + when(() => settingsRepository.fetch()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.testnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: true, + payjoinExpireAfterSec: 3600, + ), + ); + when( + () => bitcoinWalletRepository.signPsbt( + any(), + walletId: any(named: 'walletId'), + ), + ).thenAnswer((_) async => 'signed-psbt'); + when( + () => payjoinRepository.createPayjoinSender( + walletId: any(named: 'walletId'), + isTestnet: any(named: 'isTestnet'), + bip21: any(named: 'bip21'), + originalPsbt: any(named: 'originalPsbt'), + amountSat: any(named: 'amountSat'), + networkFeesSatPerVb: any(named: 'networkFeesSatPerVb'), + expireAfterSec: any(named: 'expireAfterSec'), + ), + ).thenAnswer((_) async => sender); + }); + + Future callUsecase({int? expireAfterSec}) => usecase.execute( + walletId: 'w1', + isTestnet: true, + bip21: 'bitcoin:tb1qtest?pj=https://payjo.in/x', + unsignedOriginalPsbt: 'unsigned-psbt', + amountSat: 10000, + networkFeesSatPerVb: 2, + expireAfterSec: expireAfterSec, + ); + + test('creates the session with the user-configured expiry from settings — ' + 'resolved inside the usecase, mirroring the receive side, so callers ' + 'cannot drift', () async { + await callUsecase(); + + verify( + () => payjoinRepository.createPayjoinSender( + walletId: 'w1', + isTestnet: true, + bip21: any(named: 'bip21'), + originalPsbt: 'signed-psbt', + amountSat: 10000, + networkFeesSatPerVb: 2, + expireAfterSec: 3600, + ), + ).called(1); + }); + + test('an explicit expiry override wins over the settings value', () async { + await callUsecase(expireAfterSec: 120); + + verify( + () => payjoinRepository.createPayjoinSender( + walletId: any(named: 'walletId'), + isTestnet: any(named: 'isTestnet'), + bip21: any(named: 'bip21'), + originalPsbt: any(named: 'originalPsbt'), + amountSat: any(named: 'amountSat'), + networkFeesSatPerVb: any(named: 'networkFeesSatPerVb'), + expireAfterSec: 120, + ), + ).called(1); + }); +} diff --git a/test/core_test/payjoin/watch_payjoin_usecase_test.dart b/test/core_test/payjoin/watch_payjoin_usecase_test.dart new file mode 100644 index 0000000000..c6254c8c64 --- /dev/null +++ b/test/core_test/payjoin/watch_payjoin_usecase_test.dart @@ -0,0 +1,66 @@ +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/watch_payjoin_usecase.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockPayjoinRepository extends Mock implements PayjoinRepository {} + +Payjoin _receiver(String id) => Payjoin.receiver( + id: id, + isTestnet: true, + walletId: 'w1', + pjUri: 'bitcoin:tb1q?pj=https://payjo.in', + createdAt: DateTime(2020), + expiresAt: DateTime(2020, 1, 1, 0, 5), +); + +Payjoin _sender(String uri, {PayjoinStatus status = PayjoinStatus.proposed}) => + Payjoin.sender( + status: status, + uri: uri, + isTestnet: true, + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: 'orig-txid', + amountSat: 50000, + createdAt: DateTime(2020), + expiresAt: DateTime(2020, 1, 1, 0, 5), + ); + +void main() { + late _MockPayjoinRepository repository; + late WatchPayjoinUsecase usecase; + + setUp(() { + repository = _MockPayjoinRepository(); + usecase = WatchPayjoinUsecase(payjoinRepository: repository); + }); + + test('emits both receiver and sender payjoins (senders are not filtered ' + 'out — #2246)', () async { + final receiver = _receiver('r1'); + final sender = _sender('s1'); + when( + () => repository.payjoinStream, + ).thenAnswer((_) => Stream.fromIterable([receiver, sender])); + + final emitted = await usecase.execute().toList(); + + // The send flow depends on sender events reaching it; a receiver-only + // filter here would swallow them and hang the "coordinating" screen. + expect(emitted, [receiver, sender]); + }); + + test('scopes emissions to the requested ids', () async { + final wanted = _sender('wanted'); + final other = _sender('other'); + when( + () => repository.payjoinStream, + ).thenAnswer((_) => Stream.fromIterable([wanted, other])); + + final emitted = await usecase.execute(ids: ['wanted']).toList(); + + expect(emitted, [wanted]); + }); +} diff --git a/test/features/announcements/data/mappers/announcement_dismissal_mapper_test.dart b/test/features/announcements/data/mappers/announcement_dismissal_mapper_test.dart new file mode 100644 index 0000000000..0a5dd41e1f --- /dev/null +++ b/test/features/announcements/data/mappers/announcement_dismissal_mapper_test.dart @@ -0,0 +1,31 @@ +import 'package:bb_mobile/features/announcements/data/mappers/announcement_dismissal_mapper.dart'; +import 'package:bb_mobile/features/announcements/data/models/announcement_dismissal_model.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnnouncementDismissalMapper.toEntity', () { + test('maps a known id to the domain entity', () { + final dismissedAt = DateTime.utc(2026, 7, 20, 12); + final model = AnnouncementDismissalModel( + announcementId: AnnouncementId.payjoinPrivacy.name, + dismissedAt: dismissedAt, + ); + + final entity = model.toEntity(); + + expect(entity, isNotNull); + expect(entity!.id, AnnouncementId.payjoinPrivacy); + expect(entity.dismissedAt, dismissedAt); + }); + + test('returns null for an unknown id (forward-compat downgrade)', () { + final model = AnnouncementDismissalModel( + announcementId: 'someAnnouncementFromANewerBuild', + dismissedAt: DateTime.utc(2026), + ); + + expect(model.toEntity(), isNull); + }); + }); +} diff --git a/test/features/announcements/domain/announcement_test.dart b/test/features/announcements/domain/announcement_test.dart new file mode 100644 index 0000000000..8d959d3ea9 --- /dev/null +++ b/test/features/announcements/domain/announcement_test.dart @@ -0,0 +1,52 @@ +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Announcement build({required DismissPolicy policy, int priority = 0}) { + return Announcement( + id: AnnouncementId.payjoinPrivacy, + priority: priority, + tone: AnnouncementTone.info, + action: const NavigateAction(), + dismissPolicy: policy, + ); + } + + group('Announcement invariants', () { + test('rejects a negative priority', () { + expect( + () => build(policy: const PermanentDismiss(), priority: -1), + throwsA(isA()), + ); + }); + + test('SnoozeDismiss rejects a non-positive interval', () { + expect( + () => SnoozeDismiss(const Duration(microseconds: 0)), + throwsA(isA()), + ); + }); + }); + + group('isSuppressedBy', () { + final now = DateTime(2026, 7, 20, 12); + + test('permanent policy always suppresses, regardless of age', () { + final a = build(policy: const PermanentDismiss()); + final longAgo = now.subtract(const Duration(days: 3650)); + expect(a.isSuppressedBy(longAgo, now: now), isTrue); + }); + + test('snooze policy still suppresses before the interval elapses', () { + final a = build(policy: SnoozeDismiss(const Duration(days: 90))); + final dismissedAt = now.subtract(const Duration(days: 30)); + expect(a.isSuppressedBy(dismissedAt, now: now), isTrue); + }); + + test('snooze policy re-arms once the interval elapses', () { + final a = build(policy: SnoozeDismiss(const Duration(days: 90))); + final dismissedAt = now.subtract(const Duration(days: 91)); + expect(a.isSuppressedBy(dismissedAt, now: now), isFalse); + }); + }); +} diff --git a/test/features/announcements/domain/dismiss_announcement_usecase_test.dart b/test/features/announcements/domain/dismiss_announcement_usecase_test.dart new file mode 100644 index 0000000000..a0aec283f5 --- /dev/null +++ b/test/features/announcements/domain/dismiss_announcement_usecase_test.dart @@ -0,0 +1,46 @@ +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockDismissalRepository extends Mock + implements AnnouncementDismissalRepository {} + +void main() { + late _MockDismissalRepository dismissalRepository; + late DismissAnnouncementUsecase usecase; + + setUpAll(() { + registerFallbackValue(AnnouncementId.payjoinPrivacy); + }); + + setUp(() { + dismissalRepository = _MockDismissalRepository(); + usecase = DismissAnnouncementUsecase( + dismissalRepository: dismissalRepository, + ); + }); + + test('records the dismissal and returns Ok', () async { + when(() => dismissalRepository.dismiss(any())).thenAnswer((_) async {}); + + final result = await usecase.execute(AnnouncementId.payjoinPrivacy); + + expect(result, isA>()); + verify( + () => dismissalRepository.dismiss(AnnouncementId.payjoinPrivacy), + ).called(1); + }); + + test('returns a failure when persistence throws', () async { + when( + () => dismissalRepository.dismiss(any()), + ).thenThrow(Exception('disk full')); + + final result = await usecase.execute(AnnouncementId.payjoinPrivacy); + + expect(result, isA>()); + }); +} diff --git a/test/features/announcements/domain/get_visible_announcements_usecase_test.dart b/test/features/announcements/domain/get_visible_announcements_usecase_test.dart new file mode 100644 index 0000000000..f72b6a6159 --- /dev/null +++ b/test/features/announcements/domain/get_visible_announcements_usecase_test.dart @@ -0,0 +1,173 @@ +import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart'; +import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:bb_mobile/core/swaps/domain/entity/auto_swap.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart'; +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockGetWalletTransactionsUsecase extends Mock + implements GetWalletTransactionsUsecase {} + +class _MockGetAutoSwapSettingsUsecase extends Mock + implements GetAutoSwapSettingsUsecase {} + +class _MockDismissalRepository extends Mock + implements AnnouncementDismissalRepository {} + +WalletTransaction _tx() => const WalletTransaction( + walletId: 'w1', + network: Network.bitcoinMainnet, + direction: WalletTransactionDirection.incoming, + status: WalletTransactionStatus.confirmed, + txId: 'txid', + amountSat: 1000, + feeSat: 100, + vsize: 110, + inputs: [], + outputs: [], + isRbf: false, +); + +SettingsEntity _settings({required bool payjoinEnabled}) => SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.btc, + currencyCode: 'USD', + isPayjoinEnabled: payjoinEnabled, +); + +void main() { + late _MockSettingsRepository settingsRepository; + late _MockGetWalletTransactionsUsecase getWalletTransactionsUsecase; + late _MockGetAutoSwapSettingsUsecase getAutoSwapSettingsUsecase; + late _MockDismissalRepository dismissalRepository; + late GetVisibleAnnouncementsUsecase usecase; + + setUp(() { + settingsRepository = _MockSettingsRepository(); + getWalletTransactionsUsecase = _MockGetWalletTransactionsUsecase(); + getAutoSwapSettingsUsecase = _MockGetAutoSwapSettingsUsecase(); + dismissalRepository = _MockDismissalRepository(); + usecase = GetVisibleAnnouncementsUsecase( + settingsRepository: settingsRepository, + getWalletTransactionsUsecase: getWalletTransactionsUsecase, + getAutoSwapSettingsUsecase: getAutoSwapSettingsUsecase, + dismissalRepository: dismissalRepository, + ); + }); + + void stub({ + required bool payjoinEnabled, + required bool hasHistory, + bool autoswapEnabled = false, + List dismissals = const [], + }) { + when( + () => settingsRepository.fetch(), + ).thenAnswer((_) async => _settings(payjoinEnabled: payjoinEnabled)); + when( + () => getWalletTransactionsUsecase.execute(), + ).thenAnswer((_) async => hasHistory ? [_tx()] : []); + when( + () => getAutoSwapSettingsUsecase.execute(), + ).thenAnswer((_) async => AutoSwap(enabled: autoswapEnabled)); + when( + () => dismissalRepository.getDismissals(), + ).thenAnswer((_) async => dismissals); + } + + test('shows the payjoin-privacy announcement when there is history and ' + 'payjoin is disabled', () async { + stub(payjoinEnabled: false, hasHistory: true); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list, hasLength(1)); + expect(list.single.id, AnnouncementId.payjoinPrivacy); + }); + + test('hides it when payjoin is already enabled', () async { + stub(payjoinEnabled: true, hasHistory: true); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list, isEmpty); + }); + + test('hides it when the wallet has no transaction history', () async { + stub(payjoinEnabled: false, hasHistory: false); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list, isEmpty); + }); + + test('hides it when permanently dismissed', () async { + stub( + payjoinEnabled: false, + hasHistory: true, + dismissals: [ + AnnouncementDismissal( + id: AnnouncementId.payjoinPrivacy, + dismissedAt: DateTime(2020), + ), + ], + ); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list, isEmpty); + }); + + test('shows the autoswap announcement when autoswap is enabled', () async { + stub(payjoinEnabled: true, hasHistory: false, autoswapEnabled: true); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list, hasLength(1)); + expect(list.single.id, AnnouncementId.autoswapActive); + }); + + test('shows both announcements, ordered by priority', () async { + stub(payjoinEnabled: false, hasHistory: true, autoswapEnabled: true); + + final result = await usecase.execute(); + + final list = (result as Ok, dynamic>).value; + expect(list.map((a) => a.id), [ + AnnouncementId.payjoinPrivacy, + AnnouncementId.autoswapActive, + ]); + }); + + test('returns a storage failure when a source throws', () async { + when(() => settingsRepository.fetch()).thenThrow(Exception('boom')); + when( + () => getWalletTransactionsUsecase.execute(), + ).thenAnswer((_) async => []); + when( + () => getAutoSwapSettingsUsecase.execute(), + ).thenAnswer((_) async => const AutoSwap(enabled: false)); + when( + () => dismissalRepository.getDismissals(), + ).thenAnswer((_) async => const []); + + final result = await usecase.execute(); + + expect(result, isA, dynamic>>()); + }); +} diff --git a/test/features/announcements/presentation/announcements_cubit_test.dart b/test/features/announcements/presentation/announcements_cubit_test.dart new file mode 100644 index 0000000000..bddbcae936 --- /dev/null +++ b/test/features/announcements/presentation/announcements_cubit_test.dart @@ -0,0 +1,98 @@ +import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart'; +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart'; +import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart'; +import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart'; +import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockGetVisibleAnnouncementsUsecase extends Mock + implements GetVisibleAnnouncementsUsecase {} + +class _MockDismissAnnouncementUsecase extends Mock + implements DismissAnnouncementUsecase {} + +class _MockWatchPayjoinEnabledChangesUsecase extends Mock + implements WatchPayjoinEnabledChangesUsecase {} + +class _MockWatchFinishedWalletSyncsUsecase extends Mock + implements WatchFinishedWalletSyncsUsecase {} + +Announcement _announcement() => Announcement( + id: AnnouncementId.payjoinPrivacy, + priority: 0, + tone: AnnouncementTone.info, + action: const NavigateAction(), + dismissPolicy: const PermanentDismiss(), +); + +void main() { + late _MockGetVisibleAnnouncementsUsecase getVisible; + late _MockDismissAnnouncementUsecase dismiss; + late _MockWatchPayjoinEnabledChangesUsecase watchPayjoin; + late _MockWatchFinishedWalletSyncsUsecase watchSyncs; + + setUpAll(() { + registerFallbackValue(AnnouncementId.payjoinPrivacy); + }); + + setUp(() { + getVisible = _MockGetVisibleAnnouncementsUsecase(); + dismiss = _MockDismissAnnouncementUsecase(); + watchPayjoin = _MockWatchPayjoinEnabledChangesUsecase(); + watchSyncs = _MockWatchFinishedWalletSyncsUsecase(); + // The cubit subscribes to both watchers on construction. + when( + () => watchPayjoin.execute(), + ).thenAnswer((_) => const Stream.empty()); + when( + () => watchSyncs.execute(), + ).thenAnswer((_) => const Stream.empty()); + }); + + AnnouncementsCubit build() => AnnouncementsCubit( + getVisibleAnnouncementsUsecase: getVisible, + dismissAnnouncementUsecase: dismiss, + watchPayjoinEnabledChangesUsecase: watchPayjoin, + watchFinishedWalletSyncsUsecase: watchSyncs, + ); + + test( + 'dismiss records the dismissal then refreshes the visible list', + () async { + when( + () => dismiss.execute(any()), + ).thenAnswer((_) async => const Ok(null)); + when(() => getVisible.execute()).thenAnswer( + (_) async => + Ok, AnnouncementsFailure>([_announcement()]), + ); + final cubit = build(); + addTearDown(cubit.close); + + await cubit.dismiss(AnnouncementId.payjoinPrivacy); + + expect(cubit.state.announcements, hasLength(1)); + verify(() => dismiss.execute(AnnouncementId.payjoinPrivacy)).called(1); + verify(() => getVisible.execute()).called(1); + }, + ); + + test('dismiss surfaces a failure in state and does not refresh', () async { + when(() => dismiss.execute(any())).thenAnswer( + (_) async => + const Err(AnnouncementStorageFailure()), + ); + final cubit = build(); + addTearDown(cubit.close); + + await cubit.dismiss(AnnouncementId.payjoinPrivacy); + + expect(cubit.state.failure, isA()); + verifyNever(() => getVisible.execute()); + }); +} diff --git a/test/features/receive/presentation/bloc/receive_bloc_test.dart b/test/features/receive/presentation/bloc/receive_bloc_test.dart new file mode 100644 index 0000000000..6801299505 --- /dev/null +++ b/test/features/receive/presentation/bloc/receive_bloc_test.dart @@ -0,0 +1,509 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:bb_mobile/core/entities/signer_entity.dart' show SignerEntity; +import 'package:bb_mobile/core/exchange/domain/usecases/convert_sats_to_currency_amount_usecase.dart'; +import 'package:bb_mobile/core/exchange/domain/usecases/get_available_currencies_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/broadcast_original_transaction_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dart'; +import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart'; +import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart'; +import 'package:bb_mobile/features/settings/domain/usecases/set_payjoin_enabled_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_swap_limits_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_address.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_address_at_index_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_receive_address_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_by_address_usecase.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; +import 'package:bb_mobile/features/receive/domain/usecases/create_receive_swap_use_case.dart'; +import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockGetWalletsUsecase extends Mock implements GetWalletsUsecase {} + +class _MockGetAvailableCurrenciesUsecase extends Mock + implements GetAvailableCurrenciesUsecase {} + +class _MockGetSettingsUsecase extends Mock implements GetSettingsUsecase {} + +class _MockConvertSatsToCurrencyAmountUsecase extends Mock + implements ConvertSatsToCurrencyAmountUsecase {} + +class _MockGetReceiveAddressUsecase extends Mock + implements GetReceiveAddressUsecase {} + +class _MockGetAddressAtIndexUsecase extends Mock + implements GetAddressAtIndexUsecase {} + +class _MockCreateReceiveSwapUsecase extends Mock + implements CreateReceiveSwapUsecase {} + +class _MockReceiveWithPayjoinUsecase extends Mock + implements ReceiveWithPayjoinUsecase {} + +class _MockBroadcastOriginalTransactionUsecase extends Mock + implements BroadcastOriginalTransactionUsecase {} + +class _MockWatchPayjoinUsecase extends Mock implements WatchPayjoinUsecase {} + +class _MockWatchWalletTransactionByAddressUsecase extends Mock + implements WatchWalletTransactionByAddressUsecase {} + +class _MockWatchSwapUsecase extends Mock implements WatchSwapUsecase {} + +class _MockLabelsFacade extends Mock implements LabelsFacade {} + +class _MockGetSwapLimitsUsecase extends Mock implements GetSwapLimitsUsecase {} + +class _MockWatchPayjoinEnabledChangesUsecase extends Mock + implements WatchPayjoinEnabledChangesUsecase {} + +class _MockSetPayjoinEnabledUsecase extends Mock + implements SetPayjoinEnabledUsecase {} + +// Defaults to a confirmed balance: most tests in this file are about the +// isPayjoinEnabled/proposal-state gating, not the balance one — a zero +// default would make every payjoin-creating test fail for a reason unrelated +// to what it names. The balance-eligibility tests override it explicitly. +// confirmedBalanceSat mirrors balanceSat by default: these tests are about +// the isPayjoinEnabled/eligibility gating, not the confirmed-vs-unconfirmed +// distinction, so keeping the two in lockstep here avoids every +// payjoin-creating test failing for a reason unrelated to what it names. +// confirmedBalanceSat is a separate optional override so a test can build a +// wallet with unconfirmed-only funds (balanceSat > 0, confirmedBalanceSat == +// 0) — the specific divergence _isPayjoinEligible must reject. +Wallet _testWallet({ + String origin = 'w1', + BigInt? balanceSat, + BigInt? confirmedBalanceSat, +}) => Wallet( + origin: origin, + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.local, + signerDevice: null, + balanceSat: balanceSat ?? BigInt.from(50000), + confirmedBalanceSat: confirmedBalanceSat ?? balanceSat ?? BigInt.from(50000), +); + +WalletAddress _testAddress({String walletId = 'w1'}) => WalletAddress( + walletId: walletId, + index: 0, + address: 'bc1qtest', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), +); + +PayjoinReceiver _receiver({ + String id = 'pj1', + String walletId = 'w1', + Uint8List? originalTxBytes, + String? proposalPsbt, + PayjoinStatus status = PayjoinStatus.started, +}) => + Payjoin.receiver( + status: status, + id: id, + isTestnet: false, + walletId: walletId, + pjUri: 'bitcoin:bc1qtest?pj=https://payjo.in', + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + originalTxBytes: originalTxBytes, + proposalPsbt: proposalPsbt, + ) + as PayjoinReceiver; + +void main() { + late _MockGetSettingsUsecase getSettings; + late _MockGetAvailableCurrenciesUsecase getAvailableCurrencies; + late _MockConvertSatsToCurrencyAmountUsecase convertSatsToCurrency; + late _MockGetReceiveAddressUsecase getReceiveAddress; + late _MockReceiveWithPayjoinUsecase receiveWithPayjoin; + late _MockBroadcastOriginalTransactionUsecase broadcastOriginalTransaction; + late _MockWatchPayjoinUsecase watchPayjoin; + late _MockWatchWalletTransactionByAddressUsecase watchWalletTransaction; + late _MockLabelsFacade labels; + late _MockWatchPayjoinEnabledChangesUsecase watchPayjoinEnabledChanges; + late _MockSetPayjoinEnabledUsecase setPayjoinEnabled; + late StreamController payjoinEnabledChangeController; + + setUpAll(() { + registerFallbackValue(_receiver()); + }); + + ReceiveBloc buildBloc({Wallet? wallet}) => ReceiveBloc( + getWalletsUsecase: _MockGetWalletsUsecase(), + getAvailableCurrenciesUsecase: getAvailableCurrencies, + getSettingsUsecase: getSettings, + convertSatsToCurrencyAmountUsecase: convertSatsToCurrency, + getReceiveAddressUsecase: getReceiveAddress, + getAddressAtIndexUsecase: _MockGetAddressAtIndexUsecase(), + createReceiveSwapUsecase: _MockCreateReceiveSwapUsecase(), + receiveWithPayjoinUsecase: receiveWithPayjoin, + broadcastOriginalTransactionUsecase: broadcastOriginalTransaction, + watchPayjoinUsecase: watchPayjoin, + watchWalletTransactionByAddressUsecase: watchWalletTransaction, + watchSwapUsecase: _MockWatchSwapUsecase(), + labelsFacade: labels, + getSwapLimitsUsecase: _MockGetSwapLimitsUsecase(), + watchPayjoinEnabledChangesUsecase: watchPayjoinEnabledChanges, + setPayjoinEnabledUsecase: setPayjoinEnabled, + wallet: wallet ?? _testWallet(), + ); + + setUp(() { + getSettings = _MockGetSettingsUsecase(); + getAvailableCurrencies = _MockGetAvailableCurrenciesUsecase(); + convertSatsToCurrency = _MockConvertSatsToCurrencyAmountUsecase(); + getReceiveAddress = _MockGetReceiveAddressUsecase(); + receiveWithPayjoin = _MockReceiveWithPayjoinUsecase(); + broadcastOriginalTransaction = _MockBroadcastOriginalTransactionUsecase(); + watchPayjoin = _MockWatchPayjoinUsecase(); + watchWalletTransaction = _MockWatchWalletTransactionByAddressUsecase(); + labels = _MockLabelsFacade(); + watchPayjoinEnabledChanges = _MockWatchPayjoinEnabledChangesUsecase(); + payjoinEnabledChangeController = StreamController.broadcast(); + when( + () => watchPayjoinEnabledChanges.execute(), + ).thenAnswer((_) => payjoinEnabledChangeController.stream); + setPayjoinEnabled = _MockSetPayjoinEnabledUsecase(); + // Toggling persists to settings, which in the real app feeds back via the + // change stream; the tests emit on payjoinEnabledChangeController to + // simulate that round-trip explicitly. + when(() => setPayjoinEnabled.execute(any())).thenAnswer((_) async {}); + + // Payjoin is enabled by default here so the guard group can create a + // session; the gated group overrides this stub to disable it. + when(() => getSettings.execute()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: true, + ), + ); + when(() => getAvailableCurrencies.execute()).thenAnswer((_) async => []); + when( + () => convertSatsToCurrency.execute( + amountSat: any(named: 'amountSat'), + currencyCode: any(named: 'currencyCode'), + ), + ).thenAnswer((_) async => 1.0); + when( + () => getReceiveAddress.execute(walletId: any(named: 'walletId')), + ).thenAnswer((_) async => _testAddress()); + when(() => labels.fetchByReference(any())).thenAnswer((_) async => []); + // WatchPayjoinUsecase.execute returns a Stream in the work tree. + when( + () => watchPayjoin.execute(ids: any(named: 'ids')), + ).thenAnswer((_) => const Stream.empty()); + when( + () => watchWalletTransaction.execute( + walletId: any(named: 'walletId'), + toAddress: any(named: 'toAddress'), + ), + ).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() async { + await payjoinEnabledChangeController.close(); + }); + + group('ReceivePayjoinOriginalTxBroadcasted guard', () { + test('does NOT broadcast the original once a proposal has been sent: ' + 'the sender owns finalizing/broadcasting the payjoin tx, and a ' + 'manual lower-fee rebroadcast would race/replace it', () async { + // The session already sent a proposal (proposalPsbt != null). + final proposedPayjoin = _receiver( + status: PayjoinStatus.proposed, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ).thenAnswer((_) async => proposedPayjoin); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(bloc.state.payjoin, proposedPayjoin); + + bloc.add(const ReceivePayjoinOriginalTxBroadcasted()); + await Future.delayed(Duration.zero); + + verifyNever(() => broadcastOriginalTransaction.execute(any())); + expect(bloc.state.isBroadcastingOriginalTransaction, isFalse); + }); + + test('broadcasts the original when a request was received but no ' + 'proposal went out yet (the legitimate manual fallback)', () async { + final requestedPayjoin = _receiver( + status: PayjoinStatus.requested, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + ); + when( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ).thenAnswer((_) async => requestedPayjoin); + final completedPayjoin = _receiver( + status: PayjoinStatus.aborted, + originalTxBytes: Uint8List.fromList([1, 2, 3]), + ); + when( + () => broadcastOriginalTransaction.execute(any()), + ).thenAnswer((_) async => completedPayjoin); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(bloc.state.payjoin, requestedPayjoin); + + bloc.add(const ReceivePayjoinOriginalTxBroadcasted()); + await Future.delayed(Duration.zero); + + verify( + () => broadcastOriginalTransaction.execute(requestedPayjoin), + ).called(1); + expect(bloc.state.payjoin, completedPayjoin); + expect(bloc.state.isBroadcastingOriginalTransaction, isFalse); + }); + }); + + group('payjoin gated on the global setting', () { + test('does NOT create a payjoin receiver session when payjoin is ' + 'disabled globally', () async { + when(() => getSettings.execute()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: false, + ), + ); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoin, isNull); + expect(bloc.state.payjoinGloballyEnabled, isFalse); + verifyNever( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ); + }); + + test('creates a payjoin receiver session when payjoin is enabled ' + 'globally', () async { + final createdPayjoin = _receiver(); + when( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ).thenAnswer((_) async => createdPayjoin); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoin, createdPayjoin); + expect(bloc.state.payjoinGloballyEnabled, isTrue); + }); + + test('does NOT create a payjoin receiver session for a wallet with no ' + 'confirmed balance, even though payjoin is enabled globally — a ' + 'payjoin proposal needs at least one UTXO to contribute', () async { + final bloc = buildBloc(wallet: _testWallet(balanceSat: BigInt.zero)); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoin, isNull); + expect(bloc.state.payjoinGloballyEnabled, isTrue); + expect(bloc.state.isPayjoinAwaitingFunds, isTrue); + verifyNever( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ); + }); + + test('does NOT create a payjoin receiver session for a wallet with ' + 'ONLY unconfirmed balance (balanceSat > 0 but confirmedBalanceSat == ' + '0) — a payjoin proposal needs a genuinely confirmed UTXO, an ' + 'unconfirmed one is not filtered out anywhere downstream and could ' + 'be replaced/invalidated (regression pin: _isPayjoinEligible must ' + 'check confirmedBalanceSat, not balanceSat)', () async { + final bloc = buildBloc( + wallet: _testWallet( + balanceSat: BigInt.from(50000), + confirmedBalanceSat: BigInt.zero, + ), + ); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoin, isNull); + expect(bloc.state.payjoinGloballyEnabled, isTrue); + expect(bloc.state.isPayjoinAwaitingFunds, isTrue); + verifyNever( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ); + }); + }); + + group('payjoin reacts live to the global setting changing', () { + test('creates a payjoin receiver session as soon as the setting is ' + 'flipped on, without needing to leave and re-enter the receive ' + 'screen', () async { + when(() => getSettings.execute()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: false, + ), + ); + final createdPayjoin = _receiver(); + when( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ).thenAnswer((_) async => createdPayjoin); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(bloc.state.payjoin, isNull); + + payjoinEnabledChangeController.add(true); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoinGloballyEnabled, isTrue); + expect(bloc.state.payjoin, createdPayjoin); + }); + + test('clears an existing payjoin receiver session as soon as the ' + 'setting is flipped off', () async { + final createdPayjoin = _receiver(); + when( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ).thenAnswer((_) async => createdPayjoin); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(bloc.state.payjoin, createdPayjoin); + + payjoinEnabledChangeController.add(false); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoinGloballyEnabled, isFalse); + expect(bloc.state.payjoin, isNull); + }); + + test('does NOT create a session on enable if the wallet still has no ' + 'confirmed balance', () async { + when(() => getSettings.execute()).thenAnswer( + (_) async => const SettingsEntity( + environment: Environment.mainnet, + bitcoinUnit: BitcoinUnit.sats, + currencyCode: 'USD', + isPayjoinEnabled: false, + ), + ); + + final bloc = buildBloc(wallet: _testWallet(balanceSat: BigInt.zero)); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + payjoinEnabledChangeController.add(true); + await Future.delayed(Duration.zero); + + expect(bloc.state.payjoinGloballyEnabled, isTrue); + expect(bloc.state.payjoin, isNull); + expect(bloc.state.isPayjoinAwaitingFunds, isTrue); + verifyNever( + () => receiveWithPayjoin.execute( + walletId: any(named: 'walletId'), + address: any(named: 'address'), + ), + ); + }); + }); + + group('payjoin badge toggle (ReceivePayjoinToggled)', () { + test('persists the new value to the global setting', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + + bloc.add(const ReceivePayjoinToggled(false)); + await Future.delayed(Duration.zero); + + verify(() => setPayjoinEnabled.execute(false)).called(1); + }); + + test('isPayjoinToggleable is true for a funded, locally-signing bitcoin ' + 'wallet and false for an empty one', () async { + final funded = buildBloc(); + addTearDown(funded.close); + funded.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(funded.state.isPayjoinToggleable, isTrue); + + final empty = buildBloc(wallet: _testWallet(balanceSat: BigInt.zero)); + addTearDown(empty.close); + empty.add(const ReceiveBitcoinStarted(null)); + await Future.delayed(Duration.zero); + expect(empty.state.isPayjoinToggleable, isFalse); + }); + }); +} diff --git a/test/features/receive/presentation/bloc/receive_state_test.dart b/test/features/receive/presentation/bloc/receive_state_test.dart new file mode 100644 index 0000000000..1ea5ec2789 --- /dev/null +++ b/test/features/receive/presentation/bloc/receive_state_test.dart @@ -0,0 +1,322 @@ +import 'package:bb_mobile/core/entities/signer_entity.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_address.dart'; +import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for the payjoin gating on [ReceiveState.isPayjoinLoading] and its +/// downstream effect on [ReceiveState.paymentRequest]: with payjoin disabled +/// globally, the bloc never creates a session and never sets an exception, +/// so without the [ReceiveState.payjoinGloballyEnabled] gate the QR data +/// would wait for a payjoin forever and never render — and payjoin is +/// disabled by default, so that would be every fresh install's receive +/// screen. +void main() { + // Defaults to a confirmed balance: most tests in this file are about the + // payjoinGloballyEnabled semantics, not the balance one — a zero default + // would make isPayjoinLoading false for a reason unrelated to what each + // test names. Tests about isPayjoinAwaitingFunds override it explicitly. + Wallet localWallet({BigInt? balanceSat}) => Wallet( + origin: 'test-origin', + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.local, + signerDevice: null, + balanceSat: balanceSat ?? BigInt.from(50000), + // hasUtxos gates on confirmedBalanceSat, not balanceSat — mirror it here + // so these tests keep exercising the isPayjoinLoading/isPayjoinAwaitingFunds + // semantics they name, not the confirmed-vs-total distinction. + confirmedBalanceSat: balanceSat ?? BigInt.from(50000), + ); + + WalletAddress address() => WalletAddress( + walletId: 'w1', + index: 0, + address: 'bc1qtestaddress', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + ReceiveState buildState({ + required bool? payjoinGloballyEnabled, + BigInt? balanceSat, + PayjoinReceiver? payjoin, + }) => ReceiveState( + type: ReceiveType.bitcoin, + wallet: localWallet(balanceSat: balanceSat), + bitcoinAddress: address(), + payjoinGloballyEnabled: payjoinGloballyEnabled, + payjoin: payjoin, + ); + + group('ReceiveState.isPayjoinLoading payjoin-disabled gate', () { + test('not loading when payjoin is globally disabled — no session will ever ' + 'be created, so nothing must wait for one', () { + final state = buildState(payjoinGloballyEnabled: false); + + expect(state.isPayjoinLoading, isFalse); + }); + + test('still loading while the setting has not been read yet (null): the QR ' + 'must not flash an address-only URI and then swap to a pj= BIP21', () { + final state = buildState(payjoinGloballyEnabled: null); + + expect(state.isPayjoinLoading, isTrue); + }); + + test('loading when enabled and no session or exception exists yet', () { + final state = buildState(payjoinGloballyEnabled: true); + + expect(state.isPayjoinLoading, isTrue); + }); + + test('not loading when enabled but the wallet has no confirmed balance ' + 'to contribute — no session will ever be created for it either, so ' + 'nothing must wait for one', () { + final state = buildState( + payjoinGloballyEnabled: true, + balanceSat: BigInt.zero, + ); + + expect(state.isPayjoinLoading, isFalse); + }); + }); + + group('ReceiveState.paymentRequest with payjoin disabled', () { + test('resolves to the plain address instead of waiting forever', () { + final state = buildState(payjoinGloballyEnabled: false); + + expect(state.paymentRequest, 'bc1qtestaddress'); + expect(state.qrData, 'bc1qtestaddress'); + }); + + test('stays empty (still loading) while the setting is unknown', () { + final state = buildState(payjoinGloballyEnabled: null); + + expect(state.paymentRequest, isEmpty); + }); + }); + + PayjoinReceiver payjoinWith({ + required PayjoinStatus status, + int? amountSat, + }) => + Payjoin.receiver( + status: status, + id: 'pj1', + isTestnet: true, + walletId: 'w1', + pjUri: 'bitcoin:tb1qtest?pj=https://payjo.in', + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + amountSat: amountSat, + ) + as PayjoinReceiver; + + group('ReceiveState.isPayjoinFlowOwningNavigation', () { + test('false for a non-Bitcoin receive type, even with a payjoin set', () { + final state = ReceiveState( + type: ReceiveType.liquid, + payjoin: payjoinWith(status: PayjoinStatus.requested), + ); + + expect(state.isPayjoinFlowOwningNavigation, isFalse); + }); + + test('false when there is no payjoin session at all (watch-only ' + 'wallet)', () { + const state = ReceiveState(type: ReceiveType.bitcoin); + + expect(state.isPayjoinFlowOwningNavigation, isFalse); + }); + + test('false while the payjoin session is still idle (started): a plain ' + 'send to this address, unrelated to payjoin, must still navigate ' + 'via the generic listener', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: PayjoinStatus.started), + ); + + expect(state.isPayjoinFlowOwningNavigation, isFalse); + }); + + test('true once a request has been received (requested/proposed/' + 'completed/aborted/expired) — the payjoin flow owns navigation ' + 'from here', () { + for (final status in [ + PayjoinStatus.requested, + PayjoinStatus.proposed, + PayjoinStatus.completed, + PayjoinStatus.aborted, + PayjoinStatus.expired, + ]) { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: status), + ); + + expect( + state.isPayjoinFlowOwningNavigation, + isTrue, + reason: 'status: $status', + ); + } + }); + }); + + group('ReceiveState.isPayjoinAwaitingFunds', () { + test('true when payjoin is enabled globally but this wallet has no ' + 'confirmed balance yet', () { + final state = buildState( + payjoinGloballyEnabled: true, + balanceSat: BigInt.zero, + ); + + expect(state.isPayjoinAwaitingFunds, isTrue); + }); + + test('false when the wallet already has a confirmed balance (the ' + 'normal loading/available case)', () { + final state = buildState(payjoinGloballyEnabled: true); + + expect(state.isPayjoinAwaitingFunds, isFalse); + }); + + test('false when payjoin is disabled globally, regardless of balance', () { + final state = buildState( + payjoinGloballyEnabled: false, + balanceSat: BigInt.zero, + ); + + expect(state.isPayjoinAwaitingFunds, isFalse); + }); + + test('false for a non-bitcoin receive type', () { + const state = ReceiveState( + type: ReceiveType.liquid, + payjoinGloballyEnabled: true, + ); + + expect(state.isPayjoinAwaitingFunds, isFalse); + }); + + test('false once a payjoin session already exists', () { + final state = buildState( + payjoinGloballyEnabled: true, + balanceSat: BigInt.zero, + payjoin: payjoinWith(status: PayjoinStatus.requested), + ); + + expect(state.isPayjoinAwaitingFunds, isFalse); + }); + }); + + group('ReceiveState.isPayjoinBelowMinimum', () { + test('true when the session aborted below the configured minimum', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: PayjoinStatus.aborted, amountSat: 5000), + payjoinMinAmountSat: 10000, + ); + + expect(state.isPayjoinBelowMinimum, isTrue); + }); + + test('false when the aborted amount is exactly the minimum', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: PayjoinStatus.aborted, amountSat: 10000), + payjoinMinAmountSat: 10000, + ); + + expect(state.isPayjoinBelowMinimum, isFalse); + }); + + test('false when below the minimum but not aborted (still requested)', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: PayjoinStatus.requested, amountSat: 5000), + payjoinMinAmountSat: 10000, + ); + + expect(state.isPayjoinBelowMinimum, isFalse); + }); + + test('false when the minimum is unknown (settings not read yet)', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + payjoin: payjoinWith(status: PayjoinStatus.aborted, amountSat: 5000), + payjoinMinAmountSat: null, + ); + + expect(state.isPayjoinBelowMinimum, isFalse); + }); + }); + + group('ReceiveState requested-amount payjoin suppression', () { + ReceiveState payjoinState({int? confirmedAmountSat}) => ReceiveState( + type: ReceiveType.bitcoin, + wallet: localWallet(), + bitcoinAddress: address(), + payjoinGloballyEnabled: true, + payjoinMinAmountSat: 10000, + payjoin: payjoinWith(status: PayjoinStatus.requested), + confirmedAmountSat: confirmedAmountSat, + ); + + test('canPayjoin stays true with no amount entered', () { + expect(payjoinState().canPayjoin, isTrue); + expect(payjoinState().isRequestedAmountBelowPayjoinMinimum, isFalse); + expect(payjoinState().isPayjoinSuppressedByAmount, isFalse); + }); + + test( + 'canPayjoin stays true when the amount is at or above the minimum', + () { + expect(payjoinState(confirmedAmountSat: 10000).canPayjoin, isTrue); + expect(payjoinState(confirmedAmountSat: 20000).canPayjoin, isTrue); + }, + ); + + test('canPayjoin drops to false when the requested amount is below the ' + 'minimum, and the QR no longer advertises pj=', () { + final state = payjoinState(confirmedAmountSat: 5000); + + expect(state.isRequestedAmountBelowPayjoinMinimum, isTrue); + expect(state.canPayjoin, isFalse); + expect(state.isPayjoinSuppressedByAmount, isTrue); + expect(state.paymentRequest.contains('pj='), isFalse); + // The amount is still in the URI — only payjoin is suppressed. + expect(state.paymentRequest.contains('amount='), isTrue); + }); + + test('suppression lifts as soon as the amount is raised back to the ' + 'minimum (no session teardown needed)', () { + expect(payjoinState(confirmedAmountSat: 5000).canPayjoin, isFalse); + expect(payjoinState(confirmedAmountSat: 10000).canPayjoin, isTrue); + expect( + payjoinState(confirmedAmountSat: 10000).paymentRequest, + contains('pj='), + ); + }); + + test('isPayjoinSuppressedByAmount is false when no payjoin session ' + 'exists', () { + final state = ReceiveState( + type: ReceiveType.bitcoin, + wallet: localWallet(), + bitcoinAddress: address(), + payjoinMinAmountSat: 10000, + confirmedAmountSat: 5000, + ); + + expect(state.isPayjoinSuppressedByAmount, isFalse); + }); + }); +} diff --git a/test/features/send/presentation/bloc/send_cubit_test.dart b/test/features/send/presentation/bloc/send_cubit_test.dart new file mode 100644 index 0000000000..3b1b8da679 --- /dev/null +++ b/test/features/send/presentation/bloc/send_cubit_test.dart @@ -0,0 +1,504 @@ +import 'dart:async'; + +import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart'; +import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_liquid_transaction_usecase.dart'; +import 'package:bb_mobile/core/entities/signer_entity.dart'; +import 'package:bb_mobile/core/exchange/domain/usecases/convert_sats_to_currency_amount_usecase.dart'; +import 'package:bb_mobile/core/exchange/domain/usecases/get_available_currencies_usecase.dart'; +import 'package:bb_mobile/core/fees/domain/fees_entity.dart'; +import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dart'; +import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/create_chain_swap_to_external_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/decode_invoice_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_swap_limits_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/update_send_swap_lockup_fees_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/verify_chain_swap_amount_send_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/calculate_bitcoin_absolute_fees_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/check_liquid_consolidation_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_utxos_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_by_tx_id_usecase.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; +import 'package:bb_mobile/features/send/domain/usecases/calculate_liquid_absolute_fees_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/calculate_liquid_pset_size_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/create_send_swap_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/detect_bitcoin_string_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/prepare_liquid_send_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_presets_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/select_best_wallet_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/sign_bitcoin_tx_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/sign_liquid_tx_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/update_paid_send_swap_usecase.dart'; +import 'package:bb_mobile/core/utils/payment_request.dart'; +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/features/send/presentation/bloc/send_cubit.dart'; +import 'package:bb_mobile/features/send/presentation/bloc/send_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockLabelsFacade extends Mock implements LabelsFacade {} + +class _MockSelectBestWalletUsecase extends Mock + implements SelectBestWalletUsecase {} + +class _MockDetectBitcoinStringUsecase extends Mock + implements DetectBitcoinStringUsecase {} + +class _MockGetSettingsUsecase extends Mock implements GetSettingsUsecase {} + +class _MockConvertSatsToCurrencyAmountUsecase extends Mock + implements ConvertSatsToCurrencyAmountUsecase {} + +class _MockGetNetworkFeesUsecase extends Mock + implements GetNetworkFeesUsecase {} + +class _MockGetWalletUtxosUsecase extends Mock + implements GetWalletUtxosUsecase {} + +class _MockGetAvailableCurrenciesUsecase extends Mock + implements GetAvailableCurrenciesUsecase {} + +class _MockPrepareBitcoinSendUsecase extends Mock + implements PrepareBitcoinSendUsecase {} + +class _MockPrepareLiquidSendUsecase extends Mock + implements PrepareLiquidSendUsecase {} + +class _MockSendWithPayjoinUsecase extends Mock + implements SendWithPayjoinUsecase {} + +class _MockWatchPayjoinUsecase extends Mock implements WatchPayjoinUsecase {} + +class _MockGetWalletsUsecase extends Mock implements GetWalletsUsecase {} + +class _MockGetWalletUsecase extends Mock implements GetWalletUsecase {} + +class _MockCreateSendSwapUsecase extends Mock + implements CreateSendSwapUsecase {} + +class _MockUpdatePaidSendSwapUsecase extends Mock + implements UpdatePaidSendSwapUsecase {} + +class _MockGetSwapLimitsUsecase extends Mock implements GetSwapLimitsUsecase {} + +class _MockWatchSwapUsecase extends Mock implements WatchSwapUsecase {} + +class _MockWatchFinishedWalletSyncsUsecase extends Mock + implements WatchFinishedWalletSyncsUsecase {} + +class _MockDecodeInvoiceUsecase extends Mock implements DecodeInvoiceUsecase {} + +class _MockSignBitcoinTxUsecase extends Mock implements SignBitcoinTxUsecase {} + +class _MockSignLiquidTxUsecase extends Mock implements SignLiquidTxUsecase {} + +class _MockBroadcastBitcoinTransactionUsecase extends Mock + implements BroadcastBitcoinTransactionUsecase {} + +class _MockBroadcastLiquidTransactionUsecase extends Mock + implements BroadcastLiquidTransactionUsecase {} + +class _MockCalculateLiquidAbsoluteFeesUsecase extends Mock + implements CalculateLiquidAbsoluteFeesUsecase {} + +class _MockCalculateLiquidPsetSizeUsecase extends Mock + implements CalculateLiquidPsetSizeUsecase {} + +class _MockCreateChainSwapToExternalUsecase extends Mock + implements CreateChainSwapToExternalUsecase {} + +class _MockWatchWalletTransactionByTxIdUsecase extends Mock + implements WatchWalletTransactionByTxIdUsecase {} + +class _MockCalculateBitcoinAbsoluteFeesUsecase extends Mock + implements CalculateBitcoinAbsoluteFeesUsecase {} + +class _MockUpdateSendSwapLockupFeesUsecase extends Mock + implements UpdateSendSwapLockupFeesUsecase {} + +class _MockVerifyChainSwapAmountSendUsecase extends Mock + implements VerifyChainSwapAmountSendUsecase {} + +class _MockPreviewBitcoinFeeUsecase extends Mock + implements PreviewBitcoinFeeUsecase {} + +class _MockPreviewBitcoinFeePresetsUsecase extends Mock + implements PreviewBitcoinFeePresetsUsecase {} + +class _MockCheckLiquidConsolidationUsecase extends Mock + implements CheckLiquidConsolidationUsecase {} + +class _FakeNewLabel extends Fake implements NewLabel {} + +/// Test seam: [SendCubit]'s payjoin watcher ([_watchPayjoin]) is private and +/// only started from the tail of the public [SendCubit.signTransaction]. This +/// subclass exposes [emit] so a test can stage exactly the precondition state +/// that drives `signTransaction` into its payjoin branch — nothing about the +/// production class is changed; the tests exercise the real +/// `signTransaction` → `_watchPayjoin` code path. +class _TestableSendCubit extends SendCubit { + _TestableSendCubit({ + required super.labelsFacade, + required super.bestWalletUsecase, + required super.detectBitcoinStringUsecase, + required super.getSettingsUsecase, + required super.convertSatsToCurrencyAmountUsecase, + required super.getNetworkFeesUsecase, + required super.getWalletUtxosUsecase, + required super.getAvailableCurrenciesUsecase, + required super.prepareBitcoinSendUsecase, + required super.prepareLiquidSendUsecase, + required super.sendWithPayjoinUsecase, + required super.watchPayjoinUsecase, + required super.getWalletsUsecase, + required super.getWalletUsecase, + required super.createSendSwapUsecase, + required super.updatePaidSendSwapUsecase, + required super.getSwapLimitsUsecase, + required super.watchSwapUsecase, + required super.watchFinishedWalletSyncsUsecase, + required super.decodeInvoiceUsecase, + required super.signBitcoinTxUsecase, + required super.signLiquidTxUsecase, + required super.broadcastBitcoinTxUsecase, + required super.broadcastLiquidTxUsecase, + required super.calculateLiquidAbsoluteFeesUsecase, + required super.calculateLiquidPsetSizeUsecase, + required super.createChainSwapToExternalUsecase, + required super.watchWalletTransactionByTxIdUsecase, + required super.calculateBitcoinAbsoluteFeesUsecase, + required super.updateSendSwapLockupFeesUsecase, + required super.verifyChainSwapAmountSendUsecase, + required super.previewBitcoinFeeUsecase, + required super.previewBitcoinFeePresetsUsecase, + required super.checkLiquidConsolidationUsecase, + }); + + void setStateForTest(SendState state) => emit(state); +} + +Wallet _bitcoinLocalWallet() => Wallet( + origin: 'w1', + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.local, + signerDevice: null, + balanceSat: BigInt.from(1000000), +); + +Bip21PaymentRequest _payjoinBip21() => + const PaymentRequest.bip21( + network: Network.bitcoinMainnet, + uri: 'bitcoin:bc1qaddr?amount=0.0005&pj=https://payjo.in', + address: 'bc1qaddr', + amountSat: 50000, + pj: 'https://payjo.in', + ) + as Bip21PaymentRequest; + +PayjoinSender _sender({ + required PayjoinStatus status, + String? txId, + String originalTxId = 'sender-orig-txid', +}) => + Payjoin.sender( + status: status, + uri: 'bitcoin:bc1qaddr?amount=0.0005&pj=https://payjo.in', + isTestnet: false, + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: originalTxId, + amountSat: 50000, + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + txId: txId, + ) + as PayjoinSender; + +void main() { + late _MockLabelsFacade labelsFacade; + late _MockSelectBestWalletUsecase bestWalletUsecase; + late _MockDetectBitcoinStringUsecase detectBitcoinStringUsecase; + late _MockGetSettingsUsecase getSettingsUsecase; + late _MockConvertSatsToCurrencyAmountUsecase convertSatsUsecase; + late _MockGetNetworkFeesUsecase getNetworkFeesUsecase; + late _MockGetWalletUtxosUsecase getWalletUtxosUsecase; + late _MockGetAvailableCurrenciesUsecase getAvailableCurrenciesUsecase; + late _MockPrepareBitcoinSendUsecase prepareBitcoinSendUsecase; + late _MockPrepareLiquidSendUsecase prepareLiquidSendUsecase; + late _MockSendWithPayjoinUsecase sendWithPayjoinUsecase; + late _MockWatchPayjoinUsecase watchPayjoinUsecase; + late _MockGetWalletsUsecase getWalletsUsecase; + late _MockGetWalletUsecase getWalletUsecase; + late _MockCreateSendSwapUsecase createSendSwapUsecase; + late _MockUpdatePaidSendSwapUsecase updatePaidSendSwapUsecase; + late _MockGetSwapLimitsUsecase getSwapLimitsUsecase; + late _MockWatchSwapUsecase watchSwapUsecase; + late _MockWatchFinishedWalletSyncsUsecase watchFinishedWalletSyncsUsecase; + late _MockDecodeInvoiceUsecase decodeInvoiceUsecase; + late _MockSignBitcoinTxUsecase signBitcoinTxUsecase; + late _MockSignLiquidTxUsecase signLiquidTxUsecase; + late _MockBroadcastBitcoinTransactionUsecase broadcastBitcoinTxUsecase; + late _MockBroadcastLiquidTransactionUsecase broadcastLiquidTxUsecase; + late _MockCalculateLiquidAbsoluteFeesUsecase + calculateLiquidAbsoluteFeesUsecase; + late _MockCalculateLiquidPsetSizeUsecase calculateLiquidPsetSizeUsecase; + late _MockCreateChainSwapToExternalUsecase createChainSwapToExternalUsecase; + late _MockWatchWalletTransactionByTxIdUsecase + watchWalletTransactionByTxIdUsecase; + late _MockCalculateBitcoinAbsoluteFeesUsecase + calculateBitcoinAbsoluteFeesUsecase; + late _MockUpdateSendSwapLockupFeesUsecase updateSendSwapLockupFeesUsecase; + late _MockVerifyChainSwapAmountSendUsecase verifyChainSwapAmountSendUsecase; + late _MockPreviewBitcoinFeeUsecase previewBitcoinFeeUsecase; + late _MockPreviewBitcoinFeePresetsUsecase previewBitcoinFeePresetsUsecase; + late _MockCheckLiquidConsolidationUsecase checkLiquidConsolidationUsecase; + + late StreamController payjoinEvents; + + _TestableSendCubit buildCubit() => _TestableSendCubit( + labelsFacade: labelsFacade, + bestWalletUsecase: bestWalletUsecase, + detectBitcoinStringUsecase: detectBitcoinStringUsecase, + getSettingsUsecase: getSettingsUsecase, + convertSatsToCurrencyAmountUsecase: convertSatsUsecase, + getNetworkFeesUsecase: getNetworkFeesUsecase, + getWalletUtxosUsecase: getWalletUtxosUsecase, + getAvailableCurrenciesUsecase: getAvailableCurrenciesUsecase, + prepareBitcoinSendUsecase: prepareBitcoinSendUsecase, + prepareLiquidSendUsecase: prepareLiquidSendUsecase, + sendWithPayjoinUsecase: sendWithPayjoinUsecase, + watchPayjoinUsecase: watchPayjoinUsecase, + getWalletsUsecase: getWalletsUsecase, + getWalletUsecase: getWalletUsecase, + createSendSwapUsecase: createSendSwapUsecase, + updatePaidSendSwapUsecase: updatePaidSendSwapUsecase, + getSwapLimitsUsecase: getSwapLimitsUsecase, + watchSwapUsecase: watchSwapUsecase, + watchFinishedWalletSyncsUsecase: watchFinishedWalletSyncsUsecase, + decodeInvoiceUsecase: decodeInvoiceUsecase, + signBitcoinTxUsecase: signBitcoinTxUsecase, + signLiquidTxUsecase: signLiquidTxUsecase, + broadcastBitcoinTxUsecase: broadcastBitcoinTxUsecase, + broadcastLiquidTxUsecase: broadcastLiquidTxUsecase, + calculateLiquidAbsoluteFeesUsecase: calculateLiquidAbsoluteFeesUsecase, + calculateLiquidPsetSizeUsecase: calculateLiquidPsetSizeUsecase, + createChainSwapToExternalUsecase: createChainSwapToExternalUsecase, + watchWalletTransactionByTxIdUsecase: watchWalletTransactionByTxIdUsecase, + calculateBitcoinAbsoluteFeesUsecase: calculateBitcoinAbsoluteFeesUsecase, + updateSendSwapLockupFeesUsecase: updateSendSwapLockupFeesUsecase, + verifyChainSwapAmountSendUsecase: verifyChainSwapAmountSendUsecase, + previewBitcoinFeeUsecase: previewBitcoinFeeUsecase, + previewBitcoinFeePresetsUsecase: previewBitcoinFeePresetsUsecase, + checkLiquidConsolidationUsecase: checkLiquidConsolidationUsecase, + ); + + /// Precondition state that makes [SendState.willAttemptPayjoin] true and + /// gives `signTransaction` everything its bitcoin payjoin branch reads: + /// a local-signing bitcoin wallet, a BIP21 request carrying a `pj` + /// endpoint, payjoin enabled, not a self-send, an unsigned PSBT, a + /// confirmed amount and a relative fee. [label] is the user's typed note. + SendState payjoinReadyState({String label = ''}) => SendState( + step: SendStep.confirm, + sendType: SendType.bitcoin, + selectedWallet: _bitcoinLocalWallet(), + paymentRequest: _payjoinBip21(), + payjoinGloballyEnabled: true, + isToSelf: false, + unsignedPsbt: 'cHNidP8=', + confirmedAmountSat: 50000, + label: label, + selectedFeeOption: FeeSelection.custom, + customFee: NetworkFee.relativeFromSatPerVbyte(2), + ); + + setUpAll(() { + registerFallbackValue(_FakeNewLabel()); + }); + + setUp(() { + labelsFacade = _MockLabelsFacade(); + bestWalletUsecase = _MockSelectBestWalletUsecase(); + detectBitcoinStringUsecase = _MockDetectBitcoinStringUsecase(); + getSettingsUsecase = _MockGetSettingsUsecase(); + convertSatsUsecase = _MockConvertSatsToCurrencyAmountUsecase(); + getNetworkFeesUsecase = _MockGetNetworkFeesUsecase(); + getWalletUtxosUsecase = _MockGetWalletUtxosUsecase(); + getAvailableCurrenciesUsecase = _MockGetAvailableCurrenciesUsecase(); + prepareBitcoinSendUsecase = _MockPrepareBitcoinSendUsecase(); + prepareLiquidSendUsecase = _MockPrepareLiquidSendUsecase(); + sendWithPayjoinUsecase = _MockSendWithPayjoinUsecase(); + watchPayjoinUsecase = _MockWatchPayjoinUsecase(); + getWalletsUsecase = _MockGetWalletsUsecase(); + getWalletUsecase = _MockGetWalletUsecase(); + createSendSwapUsecase = _MockCreateSendSwapUsecase(); + updatePaidSendSwapUsecase = _MockUpdatePaidSendSwapUsecase(); + getSwapLimitsUsecase = _MockGetSwapLimitsUsecase(); + watchSwapUsecase = _MockWatchSwapUsecase(); + watchFinishedWalletSyncsUsecase = _MockWatchFinishedWalletSyncsUsecase(); + decodeInvoiceUsecase = _MockDecodeInvoiceUsecase(); + signBitcoinTxUsecase = _MockSignBitcoinTxUsecase(); + signLiquidTxUsecase = _MockSignLiquidTxUsecase(); + broadcastBitcoinTxUsecase = _MockBroadcastBitcoinTransactionUsecase(); + broadcastLiquidTxUsecase = _MockBroadcastLiquidTransactionUsecase(); + calculateLiquidAbsoluteFeesUsecase = + _MockCalculateLiquidAbsoluteFeesUsecase(); + calculateLiquidPsetSizeUsecase = _MockCalculateLiquidPsetSizeUsecase(); + createChainSwapToExternalUsecase = _MockCreateChainSwapToExternalUsecase(); + watchWalletTransactionByTxIdUsecase = + _MockWatchWalletTransactionByTxIdUsecase(); + calculateBitcoinAbsoluteFeesUsecase = + _MockCalculateBitcoinAbsoluteFeesUsecase(); + updateSendSwapLockupFeesUsecase = _MockUpdateSendSwapLockupFeesUsecase(); + verifyChainSwapAmountSendUsecase = _MockVerifyChainSwapAmountSendUsecase(); + previewBitcoinFeeUsecase = _MockPreviewBitcoinFeeUsecase(); + previewBitcoinFeePresetsUsecase = _MockPreviewBitcoinFeePresetsUsecase(); + checkLiquidConsolidationUsecase = _MockCheckLiquidConsolidationUsecase(); + + payjoinEvents = StreamController.broadcast(); + + // Benign default stubs for everything the payjoin branch (or its + // aftermath) touches. + when( + () => watchPayjoinUsecase.execute(ids: any(named: 'ids')), + ).thenAnswer((_) => payjoinEvents.stream); + when( + () => getWalletUsecase.execute(any(), sync: any(named: 'sync')), + ).thenAnswer((_) async => _bitcoinLocalWallet()); + when(() => labelsFacade.store(any())).thenAnswer( + (_) async => Ok( + Label( + id: 1, + type: LabelType.transaction, + label: 'note', + reference: 'txid', + ), + ), + ); + }); + + tearDown(() async { + await payjoinEvents.close(); + }); + + /// Drives the real `signTransaction` so `_watchPayjoin` gets armed against + /// the mocked [watchPayjoinUsecase] stream, then returns the cubit. + Future<_TestableSendCubit> armWatcher( + _TestableSendCubit cubit, { + String label = '', + }) async { + when( + () => sendWithPayjoinUsecase.execute( + walletId: any(named: 'walletId'), + isTestnet: any(named: 'isTestnet'), + bip21: any(named: 'bip21'), + unsignedOriginalPsbt: any(named: 'unsignedOriginalPsbt'), + amountSat: any(named: 'amountSat'), + networkFeesSatPerVb: any(named: 'networkFeesSatPerVb'), + ), + ).thenAnswer((_) async => _sender(status: PayjoinStatus.requested)); + + cubit.setStateForTest(payjoinReadyState(label: label)); + await cubit.signTransaction(); + return cubit; + } + + group('SendCubit._watchPayjoin', () { + test('a completed PayjoinSender (real payjoin, txId set) resolves the flow ' + 'to success with the payjoin txid, syncs the wallet and stores the ' + 'user label on that final txid', () async { + final cubit = await armWatcher(buildCubit(), label: 'coffee'); + addTearDown(cubit.close); + + // signTransaction set state.txId = originalTxId provisionally. + expect(cubit.state.txId, 'sender-orig-txid'); + expect(cubit.state.step, SendStep.confirm); + + payjoinEvents.add( + _sender(status: PayjoinStatus.completed, txId: 'real-payjoin-txid'), + ); + await pumpEventQueue(); + + expect(cubit.state.step, SendStep.success); + expect(cubit.state.txId, 'real-payjoin-txid'); + // The wallet is synced (sync: true) once the payjoin resolves. + verify(() => getWalletUsecase.execute('w1', sync: true)).called(1); + // The typed label is persisted on the FINAL (payjoin) txid. + final stored = + verify(() => labelsFacade.store(captureAny())).captured.single + as NewLabel; + expect(stored.reference, 'real-payjoin-txid'); + expect(stored.label, 'coffee'); + }); + + test('an aborted PayjoinSender (fallback broadcast, txId null) resolves to ' + 'success with the ORIGINAL txid', () async { + final cubit = await armWatcher(buildCubit()); + addTearDown(cubit.close); + + payjoinEvents.add(_sender(status: PayjoinStatus.aborted)); + await pumpEventQueue(); + + expect(cubit.state.step, SendStep.success); + expect(cubit.state.txId, 'sender-orig-txid'); + }); + + test('an expired PayjoinSender returns to confirm with a broadcast-failure ' + 'exception AND clears both txId and payjoinSender so a retry starts ' + 'clean (C7 regression pin)', () async { + final cubit = await armWatcher(buildCubit()); + addTearDown(cubit.close); + + // The provisional txId + payjoinSender are set before the event. + expect(cubit.state.txId, 'sender-orig-txid'); + expect(cubit.state.payjoinSender, isNotNull); + + payjoinEvents.add(_sender(status: PayjoinStatus.expired)); + await pumpEventQueue(); + + expect(cubit.state.step, SendStep.confirm); + expect(cubit.state.confirmTransactionException, isNotNull); + expect( + cubit.state.confirmTransactionException!.isBroadcastFailure, + isTrue, + ); + // The key C7 clear: both must be nulled so broadcastTransaction's + // `txId != null` guard no longer short-circuits the retry. + expect(cubit.state.txId, isNull); + expect(cubit.state.payjoinSender, isNull); + }); + + test( + 'a non-terminal event (requested/proposed) only updates payjoinSender; ' + 'the step stays put (not success, not confirm-with-failure)', + () async { + final cubit = await armWatcher(buildCubit()); + addTearDown(cubit.close); + + final stepBefore = cubit.state.step; + + payjoinEvents.add(_sender(status: PayjoinStatus.proposed)); + await pumpEventQueue(); + + expect(cubit.state.payjoinSender, isNotNull); + expect(cubit.state.payjoinSender!.status, PayjoinStatus.proposed); + expect(cubit.state.step, stepBefore); + expect(cubit.state.step, isNot(SendStep.success)); + expect(cubit.state.confirmTransactionException, isNull); + }, + ); + }); +} diff --git a/test/features/send/presentation/bloc/send_state_test.dart b/test/features/send/presentation/bloc/send_state_test.dart index ece9c3bc80..ff780cff81 100644 --- a/test/features/send/presentation/bloc/send_state_test.dart +++ b/test/features/send/presentation/bloc/send_state_test.dart @@ -1,6 +1,8 @@ +import 'package:bb_mobile/core/entities/signer_device_entity.dart'; import 'package:bb_mobile/core/entities/signer_entity.dart'; import 'package:bb_mobile/core/fees/domain/fees_entity.dart'; import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; +import 'package:bb_mobile/core/utils/payment_request.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/features/send/presentation/bloc/send_state.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -356,4 +358,98 @@ void main() { }); }, ); + + group('SendState.willAttemptPayjoin', () { + Bip21PaymentRequest bip21WithPj({String pj = 'https://payjo.in'}) => + PaymentRequest.bip21( + network: Network.bitcoinMainnet, + uri: 'bitcoin:bc1qtest?pj=$pj', + address: 'bc1qtest', + pj: pj, + ) + as Bip21PaymentRequest; + + test('false when payjoin is disabled globally, even with a pj= URI', () { + final state = SendState( + paymentRequest: bip21WithPj(), + payjoinGloballyEnabled: false, + ); + expect(state.willAttemptPayjoin, isFalse); + }); + + test( + 'false for a self-transfer, even with a pj= URI and the setting on', + () { + final state = SendState( + paymentRequest: bip21WithPj(), + payjoinGloballyEnabled: true, + isToSelf: true, + ); + expect(state.willAttemptPayjoin, isFalse); + }, + ); + + test('false for a BIP21 URI without a pj= parameter', () { + final state = SendState( + paymentRequest: bip21WithPj(pj: ''), + payjoinGloballyEnabled: true, + ); + expect(state.willAttemptPayjoin, isFalse); + }); + + test('false for a non-BIP21 payment request', () { + final state = SendState( + paymentRequest: const PaymentRequest.bitcoin( + address: 'bc1qtest', + isTestnet: false, + ), + payjoinGloballyEnabled: true, + ); + expect(state.willAttemptPayjoin, isFalse); + }); + + test('true when enabled globally, not a self-transfer, a locally-signing ' + 'wallet, and the BIP21 URI carries a pj= parameter', () { + final state = SendState( + selectedWallet: bitcoinWallet(), + paymentRequest: bip21WithPj(), + payjoinGloballyEnabled: true, + isToSelf: false, + ); + expect(state.willAttemptPayjoin, isTrue); + }); + + test('false when no wallet is selected yet, even if every other condition ' + 'is met — fail-closed default', () { + final state = SendState( + paymentRequest: bip21WithPj(), + payjoinGloballyEnabled: true, + isToSelf: false, + ); + expect(state.willAttemptPayjoin, isFalse); + }); + + test('false for a hardware/remote-signer wallet: the confirm screen\'s ' + "device-specific sign button never reaches signTransaction's payjoin " + 'branch, so the indicator must not promise one', () { + final state = SendState( + selectedWallet: Wallet( + origin: 'test-hw-origin', + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.remote, + signerDevice: SignerDeviceEntity.ledgerNanoX, + balanceSat: BigInt.from(100000), + ), + paymentRequest: bip21WithPj(), + payjoinGloballyEnabled: true, + isToSelf: false, + ); + expect(state.willAttemptPayjoin, isFalse); + }); + }); } diff --git a/test/features/transactions/domain/entities/transaction_test.dart b/test/features/transactions/domain/entities/transaction_test.dart new file mode 100644 index 0000000000..bc1f105cf4 --- /dev/null +++ b/test/features/transactions/domain/entities/transaction_test.dart @@ -0,0 +1,279 @@ +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart'; +import 'package:bb_mobile/features/transactions/domain/entities/transaction.dart'; +import 'package:flutter_test/flutter_test.dart'; + +WalletTransaction _walletTx({ + required String txId, + WalletTransactionDirection direction = WalletTransactionDirection.outgoing, + int amountSat = 50000, +}) => WalletTransaction( + walletId: 'w1', + network: Network.bitcoinMainnet, + direction: direction, + status: WalletTransactionStatus.pending, + txId: txId, + amountSat: amountSat, + feeSat: 500, + vsize: 150, + inputs: const [], + outputs: const [], + isRbf: false, +); + +Payjoin _senderPayjoin({ + PayjoinStatus status = PayjoinStatus.requested, + String originalTxId = 'orig-txid', + String? txId, +}) => Payjoin.sender( + status: status, + uri: 'bitcoin:tb1qsender?pj=https://payjo.in', + isTestnet: false, + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: originalTxId, + amountSat: 50000, + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 1, 0, 5), + txId: txId, +); + +void main() { + group('Transaction.displayPayjoinStatus', () { + test('is null when the transaction has no payjoin', () { + expect(const Transaction().displayPayjoinStatus, isNull); + expect( + Transaction( + walletTransaction: _walletTx(txId: 'any'), + ).displayPayjoinStatus, + isNull, + ); + }); + + test('passes the session status through while nothing is broadcast', () { + for (final status in [ + PayjoinStatus.requested, + PayjoinStatus.proposed, + PayjoinStatus.completed, + PayjoinStatus.aborted, + PayjoinStatus.expired, + ]) { + expect( + Transaction( + payjoin: _senderPayjoin(status: status), + ).displayPayjoinStatus, + status, + ); + } + }); + + test('derives completed from the wallet transaction being the payjoin ' + 'transaction, even while the session row still lags on ' + 'proposed', () { + final transaction = Transaction( + walletTransaction: _walletTx(txId: 'payjoin-txid'), + payjoin: _senderPayjoin( + status: PayjoinStatus.proposed, + txId: 'payjoin-txid', + ), + ); + + expect(transaction.displayPayjoinStatus, PayjoinStatus.completed); + }); + + test('derives aborted (fallback) from the wallet transaction being the ' + 'ORIGINAL transaction, even while the session row still lags on ' + 'requested', () { + final transaction = Transaction( + walletTransaction: _walletTx(txId: 'orig-txid'), + payjoin: _senderPayjoin(status: PayjoinStatus.requested), + ); + + expect(transaction.displayPayjoinStatus, PayjoinStatus.aborted); + }); + + test('never downgrades a real payjoin completion to fallback', () { + // Degenerate ordering safety: a session already marked completed keeps + // that status regardless of which transaction this record was paired + // with. + final transaction = Transaction( + walletTransaction: _walletTx(txId: 'orig-txid'), + payjoin: _senderPayjoin( + status: PayjoinStatus.completed, + txId: 'payjoin-txid', + ), + ); + + expect(transaction.displayPayjoinStatus, PayjoinStatus.completed); + }); + + test('keeps the session status when the wallet transaction matches ' + 'neither txid', () { + final transaction = Transaction( + walletTransaction: _walletTx(txId: 'unrelated-txid'), + payjoin: _senderPayjoin(status: PayjoinStatus.proposed), + ); + + expect(transaction.displayPayjoinStatus, PayjoinStatus.proposed); + }); + }); + + group('Transaction.payjoinFeeContributionSat', () { + WalletTransaction buildWalletTransaction(int amountSat) => + WalletTransaction( + walletId: 'w1', + network: Network.bitcoinMainnet, + direction: WalletTransactionDirection.incoming, + status: WalletTransactionStatus.confirmed, + txId: 'a' * 64, + amountSat: amountSat, + feeSat: 200, + vsize: 250, + inputs: const [], + outputs: const [], + isRbf: false, + ); + + Payjoin buildReceiver({ + required int amountSat, + required PayjoinStatus status, + String? txId, + }) => Payjoin.receiver( + status: status, + id: 'r1', + isTestnet: false, + walletId: 'w1', + pjUri: 'bitcoin:addr?pj=https://payjo.in/x', + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 2), + amountSat: amountSat, + txId: txId, + ); + + test('derives the gap between the negotiated amount and the wallet-visible ' + 'amount for a completed receiver payjoin', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.completed, + ), + ); + + expect(transaction.payjoinFeeContributionSat, 54); + }); + + test('also applies while merely proposed when the broadcast tx IS the ' + 'proposal (txid match) — mirrors the existing status-display ' + 'heuristic, since a receiver session may never reach `completed` ' + 'without watch-for-broadcast', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.proposed, + txId: 'a' * 64, // same as the wallet transaction's txId + ), + ); + + expect(transaction.payjoinFeeContributionSat, 54); + }); + + test('null while proposed when the broadcast tx is NOT the proposal (e.g. ' + 'the original transaction, joined to the session by originalTxId): a ' + 'fallback contributed no input, so no fee-contribution row', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.proposed, + txId: 'b' * 64, // proposal txid differs from the broadcast tx + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + + test('null when there is no gap (amounts match)', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(1002), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.completed, + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + + test('null when the wallet actually received more (a negative gap)', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(1100), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.completed, + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + + test('null for a sender payjoin (receive-side only)', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: Payjoin.sender( + status: PayjoinStatus.completed, + uri: 'bitcoin:addr?pj=https://payjo.in/x', + isTestnet: false, + walletId: 'w1', + originalPsbt: 'psbt', + originalTxId: 'a' * 64, + amountSat: 1002, + createdAt: DateTime(2026), + expiresAt: DateTime(2026, 1, 2), + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + + test('null when the session never resolved (still just requested)', () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.requested, + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + + test( + 'null for an aborted session (a plain broadcast, not a real payjoin)', + () { + final transaction = Transaction( + walletTransaction: buildWalletTransaction(948), + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.aborted, + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }, + ); + + test('null without a broadcast transaction yet', () { + final transaction = Transaction( + payjoin: buildReceiver( + amountSat: 1002, + status: PayjoinStatus.completed, + ), + ); + + expect(transaction.payjoinFeeContributionSat, isNull); + }); + }); +} diff --git a/test/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit_test.dart b/test/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit_test.dart new file mode 100644 index 0000000000..53d1fe121a --- /dev/null +++ b/test/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit_test.dart @@ -0,0 +1,577 @@ +import 'dart:async'; + +import 'package:bb_mobile/core/entities/signer_entity.dart' show SignerEntity; +import 'package:bb_mobile/core/exchange/domain/usecases/get_order_usercase.dart'; +import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/broadcast_original_transaction_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/get_payjoin_by_id_usecase.dart'; +import 'package:bb_mobile/core/payjoin/domain/usecases/watch_payjoin_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/get_swap_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/process_swap_usecase.dart'; +import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart'; +import 'package:bb_mobile/core/utils/result.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; +import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transaction_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/wallet_failure.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_usecase.dart'; +import 'package:bb_mobile/core/wallet/domain/usecases/watch_wallet_transaction_by_tx_id_usecase.dart'; +import 'package:bb_mobile/features/labels/labels_facade.dart'; +import 'package:bb_mobile/features/transactions/application/usecases/get_transactions_by_tx_id_usecase.dart'; +import 'package:bb_mobile/features/transactions/domain/entities/transaction.dart'; +import 'package:bb_mobile/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockGetWalletUsecase extends Mock implements GetWalletUsecase {} + +class _MockGetTransactionsByTxIdUsecase extends Mock + implements GetTransactionsByTxIdUsecase {} + +class _MockGetWalletTransactionUsecase extends Mock + implements GetWalletTransactionUsecase {} + +class _MockWatchWalletTransactionByTxIdUsecase extends Mock + implements WatchWalletTransactionByTxIdUsecase {} + +class _MockGetSwapUsecase extends Mock implements GetSwapUsecase {} + +class _MockGetPayjoinByIdUsecase extends Mock + implements GetPayjoinByIdUsecase {} + +class _MockGetOrderUsecase extends Mock implements GetOrderUsecase {} + +class _MockWatchSwapUsecase extends Mock implements WatchSwapUsecase {} + +class _MockWatchPayjoinUsecase extends Mock implements WatchPayjoinUsecase {} + +class _MockLabelsFacade extends Mock implements LabelsFacade {} + +class _MockBroadcastOriginalTransactionUsecase extends Mock + implements BroadcastOriginalTransactionUsecase {} + +class _MockProcessSwapUsecase extends Mock implements ProcessSwapUsecase {} + +Wallet _testWallet({String origin = 'w1'}) => Wallet( + origin: origin, + network: Network.bitcoinMainnet, + xpubFingerprint: '00000000', + scriptType: ScriptType.bip84, + xpub: '', + externalPublicDescriptor: '', + internalPublicDescriptor: '', + signer: SignerEntity.local, + signerDevice: null, + balanceSat: BigInt.zero, +); + +WalletTransaction _walletTx({required String txId, String walletId = 'w1'}) => + WalletTransaction( + walletId: walletId, + network: Network.bitcoinMainnet, + direction: WalletTransactionDirection.outgoing, + status: WalletTransactionStatus.pending, + txId: txId, + amountSat: 50000, + feeSat: 500, + vsize: 150, + inputs: const [], + outputs: const [], + isRbf: false, + ); + +PayjoinSender _sender({ + required PayjoinStatus status, + String? txId, + String? proposalPsbt, +}) => + Payjoin.sender( + status: status, + uri: 'bitcoin:tb1qsender?pj=https://payjo.in', + isTestnet: true, + walletId: 'w1', + originalPsbt: 'cHNidP8=', + originalTxId: 'sender-orig-txid', + amountSat: 50000, + createdAt: DateTime(2026), + expiresAt: DateTime(2026).add(const Duration(minutes: 1)), + txId: txId, + proposalPsbt: proposalPsbt, + ) + as PayjoinSender; + +void main() { + late _MockGetWalletUsecase getWallet; + late _MockGetTransactionsByTxIdUsecase getTransactionsByTxId; + late _MockGetWalletTransactionUsecase getWalletTransaction; + late _MockGetPayjoinByIdUsecase getPayjoinById; + late _MockWatchPayjoinUsecase watchPayjoin; + late _MockWatchWalletTransactionByTxIdUsecase watchWalletTransactionByTxId; + late _MockBroadcastOriginalTransactionUsecase broadcastOriginalTransaction; + + TransactionDetailsCubit buildCubit() => TransactionDetailsCubit( + getWalletUsecase: getWallet, + getTransactionsByTxIdUsecase: getTransactionsByTxId, + getWalletTransactionUsecase: getWalletTransaction, + watchWalletTransactionByTxIdUsecase: watchWalletTransactionByTxId, + getSwapUsecase: _MockGetSwapUsecase(), + getPayjoinByIdUsecase: getPayjoinById, + getOrderUsecase: _MockGetOrderUsecase(), + watchSwapUsecase: _MockWatchSwapUsecase(), + watchPayjoinUsecase: watchPayjoin, + labelsFacade: _MockLabelsFacade(), + broadcastOriginalTransactionUsecase: broadcastOriginalTransaction, + processSwapUsecase: _MockProcessSwapUsecase(), + ); + + setUpAll(() { + registerFallbackValue(_sender(status: PayjoinStatus.requested)); + }); + + setUp(() { + getWallet = _MockGetWalletUsecase(); + getTransactionsByTxId = _MockGetTransactionsByTxIdUsecase(); + getWalletTransaction = _MockGetWalletTransactionUsecase(); + getPayjoinById = _MockGetPayjoinByIdUsecase(); + watchPayjoin = _MockWatchPayjoinUsecase(); + watchWalletTransactionByTxId = _MockWatchWalletTransactionByTxIdUsecase(); + broadcastOriginalTransaction = _MockBroadcastOriginalTransactionUsecase(); + + when( + () => getWallet.execute(any(), sync: any(named: 'sync')), + ).thenAnswer((_) async => _testWallet()); + // By default the forced sync'd lookup finds nothing — individual tests + // override it to simulate the broadcast becoming visible on demand. The + // usecase returns a Result now, so the "nothing" case is Ok(null). + when( + () => getWalletTransaction.execute( + txId: any(named: 'txId'), + walletId: any(named: 'walletId'), + sync: any(named: 'sync'), + ), + ).thenAnswer( + (_) async => + const Ok(null), + ); + when( + () => watchPayjoin.execute(ids: any(named: 'ids')), + ).thenAnswer((_) => const Stream.empty()); + // _loadDetailsByPayjoinId always arms a watcher for both payjoin.txId + // (when set) and payjoin.originalTxId (always set on our fixtures) — + // an unstubbed call here throws synchronously (mocktail returns null, + // and .listen() on null throws), silently short-circuiting + // _loadDetailsByPayjoinId's try/catch before state.transaction is ever + // populated, which would make every guard test a false positive (the + // guard never actually runs because state.payjoin stayed null). + when( + () => watchWalletTransactionByTxId.execute( + txId: any(named: 'txId'), + walletId: any(named: 'walletId'), + ), + ).thenAnswer((_) => const Stream.empty()); + }); + + group('TransactionDetailsCubit.broadcastPayjoinOriginalTx guard', () { + test( + 'does NOT broadcast once the session already completed via a real ' + 'payjoin: re-broadcasting the lower-fee original would race an ' + 'already-broadcast payjoin transaction spending the same inputs', + () async { + final payjoin = _sender( + status: PayjoinStatus.completed, + txId: 'real-payjoin-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + await cubit.broadcastPayjoinOriginalTx(); + + verifyNever(() => broadcastOriginalTransaction.execute(any())); + expect(cubit.state.isBroadcastingPayjoinOriginalTx, isFalse); + }, + ); + + test('does NOT broadcast once already completed via the plain-' + 'broadcast fallback (PayjoinStatus.aborted — same isCompleted as a ' + 'real payjoin, nothing left to do)', () async { + final payjoin = _sender(status: PayjoinStatus.aborted); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + await cubit.broadcastPayjoinOriginalTx(); + + verifyNever(() => broadcastOriginalTransaction.execute(any())); + }); + + test('does NOT broadcast while a proposal is still being actively ' + 'processed (received, not yet completed or expired)', () async { + final payjoin = _sender( + status: PayjoinStatus.proposed, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + await cubit.broadcastPayjoinOriginalTx(); + + verifyNever(() => broadcastOriginalTransaction.execute(any())); + }); + + test('broadcasts while waiting for a proposal (the legitimate manual ' + 'fallback)', () async { + final payjoin = _sender(status: PayjoinStatus.requested); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + final completed = _sender(status: PayjoinStatus.aborted); + when( + () => broadcastOriginalTransaction.execute(any()), + ).thenAnswer((_) async => completed); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + await cubit.broadcastPayjoinOriginalTx(); + + verify(() => broadcastOriginalTransaction.execute(payjoin)).called(1); + expect(cubit.state.payjoin, completed); + }); + + test('allows a manual retry once the repository\'s own internal ' + 'fallback also gave up (expired, proposalPsbt still set)', () async { + final payjoin = _sender( + status: PayjoinStatus.expired, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + final completed = _sender(status: PayjoinStatus.aborted); + when( + () => broadcastOriginalTransaction.execute(any()), + ).thenAnswer((_) async => completed); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + await cubit.broadcastPayjoinOriginalTx(); + + verify(() => broadcastOriginalTransaction.execute(payjoin)).called(1); + }); + }); + + group('TransactionDetailsCubit.initByPayjoinId broadcast resolution', () { + test( + 'resolves straight to the wallet transaction when the broadcast is ' + 'already visible locally — the screen must show the pending bitcoin ' + 'transaction, not payjoin-session-only data (observed live: a ' + 'fallback-completed send showing a stale "requested" session)', + () async { + // Session row still lagging on requested, but the ORIGINAL + // transaction (the fallback broadcast) is already in the wallet. + final payjoin = _sender(status: PayjoinStatus.requested); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + when( + () => getTransactionsByTxId.execute('sender-orig-txid'), + ).thenAnswer( + (_) async => [ + Transaction( + walletTransaction: _walletTx(txId: 'sender-orig-txid'), + payjoin: payjoin, + ), + ], + ); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + expect(cubit.state.transaction?.walletTransaction, isNotNull); + expect( + cubit.state.transaction?.walletTransaction?.txId, + 'sender-orig-txid', + ); + // And the displayed payjoin status derives "aborted" (fallback) + // from the original transaction being the one on-chain, despite + // the stale session row. + expect( + cubit.state.transaction?.displayPayjoinStatus, + PayjoinStatus.aborted, + ); + }, + ); + + test('prefers the payjoin transaction over the original when the session ' + 'completed for real', () async { + final payjoin = _sender( + status: PayjoinStatus.completed, + txId: 'real-payjoin-txid', + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + when(() => getTransactionsByTxId.execute('real-payjoin-txid')).thenAnswer( + (_) async => [ + Transaction( + walletTransaction: _walletTx(txId: 'real-payjoin-txid'), + payjoin: payjoin, + ), + ], + ); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + expect( + cubit.state.transaction?.walletTransaction?.txId, + 'real-payjoin-txid', + ); + expect( + cubit.state.transaction?.displayPayjoinStatus, + PayjoinStatus.completed, + ); + }); + + test('stays on payjoin-session data while nothing is broadcast, without ' + 'firing a targeted sync for a still-ongoing session', () async { + final payjoin = _sender(status: PayjoinStatus.requested); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + // Nothing visible in any wallet for either txid. + when( + () => getTransactionsByTxId.execute(any()), + ).thenAnswer((_) async => [Transaction(payjoin: payjoin)]); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + expect(cubit.state.transaction?.walletTransaction, isNull); + expect(cubit.state.payjoin, payjoin); + verifyNever(() => getWallet.execute(any(), sync: true)); + }); + + test('waits for a forced sync\'d lookup and lands DIRECTLY on the wallet ' + 'transaction when the broadcast was not visible locally yet — no ' + 'payjoin-session placeholder that swaps out moments later ' + '(observed live on the receiver side of an aborted payjoin)', () async { + final payjoin = _sender(status: PayjoinStatus.aborted); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + + // Invisible locally until the forced sync'd lookup pulls it in. + var visible = false; + when(() => getTransactionsByTxId.execute('sender-orig-txid')).thenAnswer( + (_) async => [ + if (visible) + Transaction( + walletTransaction: _walletTx(txId: 'sender-orig-txid'), + payjoin: payjoin, + ) + else + Transaction(payjoin: payjoin), + ], + ); + when( + () => getWalletTransaction.execute( + txId: 'sender-orig-txid', + walletId: 'w1', + sync: true, + ), + ).thenAnswer((_) async { + visible = true; + return Ok( + _walletTx(txId: 'sender-orig-txid'), + ); + }); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + expect( + cubit.state.transaction?.walletTransaction?.txId, + 'sender-orig-txid', + ); + expect( + cubit.state.transaction?.displayPayjoinStatus, + PayjoinStatus.aborted, + ); + }); + + test( + 'does not force a sync\'d lookup for a still-ongoing session', + () async { + final payjoin = _sender(status: PayjoinStatus.requested); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + when( + () => getTransactionsByTxId.execute(any()), + ).thenAnswer((_) async => [Transaction(payjoin: payjoin)]); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + + verifyNever( + () => getWalletTransaction.execute( + txId: any(named: 'txId'), + walletId: any(named: 'walletId'), + sync: any(named: 'sync'), + ), + ); + }, + ); + + test('fires a targeted wallet sync when the session is resolved but its ' + 'broadcast transaction is not visible locally yet', () async { + final payjoin = _sender(status: PayjoinStatus.aborted); + when( + () => getPayjoinById.execute(payjoin.uri), + ).thenAnswer((_) async => payjoin); + when( + () => getTransactionsByTxId.execute(any()), + ).thenAnswer((_) async => [Transaction(payjoin: payjoin)]); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByPayjoinId(payjoin.uri); + await pumpEventQueue(); + + expect(cubit.state.transaction?.walletTransaction, isNull); + verify(() => getWallet.execute('w1', sync: true)).called(1); + }); + }); + + group('TransactionDetailsCubit payjoin reactivity on the by-tx-id path', () { + test( + 'a payjoin event reloads the details immediately — the manual-broadcast ' + 'guard flips the moment the repository resolves the session, without ' + 'waiting for a wallet sync (observed live: a stale "Send without ' + 'payjoin" button lingering after the fallback had already broadcast)', + () async { + final ongoing = _sender(status: PayjoinStatus.requested); + final completedViaFallback = _sender(status: PayjoinStatus.aborted); + final payjoinEvents = StreamController.broadcast(); + addTearDown(payjoinEvents.close); + + var loadCount = 0; + when(() => getTransactionsByTxId.execute(any())).thenAnswer( + (_) async => [ + Transaction( + payjoin: loadCount++ == 0 ? ongoing : completedViaFallback, + ), + ], + ); + when( + () => watchPayjoin.execute(ids: [ongoing.id]), + ).thenAnswer((_) => payjoinEvents.stream); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByWalletTxId('sender-orig-txid', walletId: 'w1'); + + expect(cubit.state.payjoin?.canManuallyBroadcastOriginal, isTrue); + + payjoinEvents.add(completedViaFallback); + await pumpEventQueue(); + + // Work-tree divergence from payjoin-hardening: a fallback completion + // is an explicit `aborted` status here (not `isCompleted`), per the + // Payjoin entity's status semantics. Either way it is terminal, and + // the manual-broadcast guard flips shut. + expect(cubit.state.payjoin?.isAborted, isTrue); + expect(cubit.state.payjoin?.isOngoing, isFalse); + expect(cubit.state.payjoin?.canManuallyBroadcastOriginal, isFalse); + }, + ); + + test('a TERMINAL payjoin event with no wallet transaction on screen yet ' + 'fires a targeted sync of the wallet, so the broadcast transaction ' + 'shows up promptly instead of at the next scheduled sync', () async { + final ongoing = _sender(status: PayjoinStatus.requested); + final completedViaFallback = _sender(status: PayjoinStatus.aborted); + final payjoinEvents = StreamController.broadcast(); + addTearDown(payjoinEvents.close); + + var loadCount = 0; + when(() => getTransactionsByTxId.execute(any())).thenAnswer( + (_) async => [ + Transaction( + payjoin: loadCount++ == 0 ? ongoing : completedViaFallback, + ), + ], + ); + when( + () => watchPayjoin.execute(ids: [ongoing.id]), + ).thenAnswer((_) => payjoinEvents.stream); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByWalletTxId('sender-orig-txid', walletId: 'w1'); + + payjoinEvents.add(completedViaFallback); + await pumpEventQueue(); + + verify(() => getWallet.execute('w1', sync: true)).called(1); + }); + + test( + 'a NON-terminal payjoin event does not fire the targeted sync', + () async { + final ongoing = _sender(status: PayjoinStatus.requested); + final proposed = _sender( + status: PayjoinStatus.proposed, + proposalPsbt: 'cHNidP9wcm9wb3NhbA==', + ); + final payjoinEvents = StreamController.broadcast(); + addTearDown(payjoinEvents.close); + + var loadCount = 0; + when(() => getTransactionsByTxId.execute(any())).thenAnswer( + (_) async => [ + Transaction(payjoin: loadCount++ == 0 ? ongoing : proposed), + ], + ); + when( + () => watchPayjoin.execute(ids: [ongoing.id]), + ).thenAnswer((_) => payjoinEvents.stream); + + final cubit = buildCubit(); + addTearDown(cubit.close); + await cubit.initByWalletTxId('sender-orig-txid', walletId: 'w1'); + + payjoinEvents.add(proposed); + await pumpEventQueue(); + + expect(cubit.state.payjoin?.status, PayjoinStatus.proposed); + verifyNever(() => getWallet.execute(any(), sync: true)); + }, + ); + }); +} diff --git a/test/migrations_test/bull_database/generated/schema_v14.dart b/test/migrations_test/bull_database/generated/schema_v14.dart index edf74043bb..e3e01b4cef 100644 --- a/test/migrations_test/bull_database/generated/schema_v14.dart +++ b/test/migrations_test/bull_database/generated/schema_v14.dart @@ -8156,6 +8156,213 @@ class FrozenUtxosCompanion extends UpdateCompanion { } } +class DismissedAnnouncements extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + DismissedAnnouncements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn announcementId = GeneratedColumn( + 'announcement_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dismissedAt = GeneratedColumn( + 'dismissed_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [announcementId, dismissedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'dismissed_announcements'; + @override + Set get $primaryKey => {announcementId}; + @override + DismissedAnnouncementsData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DismissedAnnouncementsData( + announcementId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}announcement_id'], + )!, + dismissedAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}dismissed_at'], + )!, + ); + } + + @override + DismissedAnnouncements createAlias(String alias) { + return DismissedAnnouncements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(announcement_id)']; + @override + bool get dontWriteConstraints => true; +} + +class DismissedAnnouncementsData extends DataClass + implements Insertable { + final String announcementId; + final String dismissedAt; + const DismissedAnnouncementsData({ + required this.announcementId, + required this.dismissedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['announcement_id'] = Variable(announcementId); + map['dismissed_at'] = Variable(dismissedAt); + return map; + } + + DismissedAnnouncementsCompanion toCompanion(bool nullToAbsent) { + return DismissedAnnouncementsCompanion( + announcementId: Value(announcementId), + dismissedAt: Value(dismissedAt), + ); + } + + factory DismissedAnnouncementsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DismissedAnnouncementsData( + announcementId: serializer.fromJson(json['announcementId']), + dismissedAt: serializer.fromJson(json['dismissedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'announcementId': serializer.toJson(announcementId), + 'dismissedAt': serializer.toJson(dismissedAt), + }; + } + + DismissedAnnouncementsData copyWith({ + String? announcementId, + String? dismissedAt, + }) => DismissedAnnouncementsData( + announcementId: announcementId ?? this.announcementId, + dismissedAt: dismissedAt ?? this.dismissedAt, + ); + DismissedAnnouncementsData copyWithCompanion( + DismissedAnnouncementsCompanion data, + ) { + return DismissedAnnouncementsData( + announcementId: data.announcementId.present + ? data.announcementId.value + : this.announcementId, + dismissedAt: data.dismissedAt.present + ? data.dismissedAt.value + : this.dismissedAt, + ); + } + + @override + String toString() { + return (StringBuffer('DismissedAnnouncementsData(') + ..write('announcementId: $announcementId, ') + ..write('dismissedAt: $dismissedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(announcementId, dismissedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DismissedAnnouncementsData && + other.announcementId == this.announcementId && + other.dismissedAt == this.dismissedAt); +} + +class DismissedAnnouncementsCompanion + extends UpdateCompanion { + final Value announcementId; + final Value dismissedAt; + final Value rowid; + const DismissedAnnouncementsCompanion({ + this.announcementId = const Value.absent(), + this.dismissedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + DismissedAnnouncementsCompanion.insert({ + required String announcementId, + required String dismissedAt, + this.rowid = const Value.absent(), + }) : announcementId = Value(announcementId), + dismissedAt = Value(dismissedAt); + static Insertable custom({ + Expression? announcementId, + Expression? dismissedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (announcementId != null) 'announcement_id': announcementId, + if (dismissedAt != null) 'dismissed_at': dismissedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + DismissedAnnouncementsCompanion copyWith({ + Value? announcementId, + Value? dismissedAt, + Value? rowid, + }) { + return DismissedAnnouncementsCompanion( + announcementId: announcementId ?? this.announcementId, + dismissedAt: dismissedAt ?? this.dismissedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (announcementId.present) { + map['announcement_id'] = Variable(announcementId.value); + } + if (dismissedAt.present) { + map['dismissed_at'] = Variable(dismissedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DismissedAnnouncementsCompanion(') + ..write('announcementId: $announcementId, ') + ..write('dismissedAt: $dismissedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class DatabaseAtV14 extends GeneratedDatabase { DatabaseAtV14(QueryExecutor e) : super(e); late final Transactions transactions = Transactions(this); @@ -8174,6 +8381,8 @@ class DatabaseAtV14 extends GeneratedDatabase { late final Recoverbull recoverbull = Recoverbull(this); late final Prices prices = Prices(this); late final FrozenUtxos frozenUtxos = FrozenUtxos(this); + late final DismissedAnnouncements dismissedAnnouncements = + DismissedAnnouncements(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -8195,6 +8404,7 @@ class DatabaseAtV14 extends GeneratedDatabase { recoverbull, prices, frozenUtxos, + dismissedAnnouncements, ]; @override int get schemaVersion => 14; diff --git a/test/migrations_test/bull_database/schema_v13_to_v14_test.dart b/test/migrations_test/bull_database/schema_v13_to_v14_test.dart index 5ed453afd3..ee0419ce6d 100644 --- a/test/migrations_test/bull_database/schema_v13_to_v14_test.dart +++ b/test/migrations_test/bull_database/schema_v13_to_v14_test.dart @@ -103,5 +103,44 @@ void main() { expect(senders.single.isAborted, 0); await migratedDb.close(); }); + + test('creates the dismissed_announcements table (empty by default) and it ' + 'accepts a row', () async { + final schema = await verifier.schemaAt(13); + + final db = SqliteDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 14); + await db.close(); + + final migratedDb = v14.DatabaseAtV14(schema.newConnection()); + + // Newly created table starts empty. + final before = await migratedDb + .select(migratedDb.dismissedAnnouncements) + .get(); + expect(before, isEmpty); + + // And it is writable/readable. The DB stores DateTime as ISO-8601 text + // (storeDateTimeAsText: true), so the generated v14 companion takes a + // String for this column. + const dismissedAt = '2026-07-20T00:00:00.000Z'; + await migratedDb + .into(migratedDb.dismissedAnnouncements) + .insert( + v14.DismissedAnnouncementsCompanion.insert( + announcementId: 'payjoinPrivacy', + dismissedAt: dismissedAt, + ), + ); + + final after = await migratedDb + .select(migratedDb.dismissedAnnouncements) + .get(); + expect(after, hasLength(1)); + expect(after.single.announcementId, 'payjoinPrivacy'); + expect(after.single.dismissedAt, dismissedAt); + + await migratedDb.close(); + }); }); }