@@ -5,6 +5,7 @@ import 'dart:developer';
55import 'package:bb_mobile/core/errors/bull_exception.dart' ;
66import 'package:bb_mobile/core/payjoin/data/models/payjoin_input_pair_model.dart' ;
77import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart' ;
8+ import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart' show Payjoin;
89import 'package:bb_mobile/core/utils/bitcoin_tx.dart' ;
910import 'package:bb_mobile/core/utils/constants.dart' ;
1011import 'package:bb_mobile/core/utils/logger.dart' as logger;
@@ -43,6 +44,8 @@ class PdkPayjoinDatasource {
4344 final Set <String > _receiverPollsInFlight = {};
4445 final Set <String > _senderPollsInFlight = {};
4546
47+ bool _disposed = false ;
48+
4649 PdkPayjoinDatasource ({
4750 this ._payjoinDirectoryUrl = PayjoinConstants .directoryUrl,
4851 required this ._dio,
@@ -60,6 +63,46 @@ class PdkPayjoinDatasource {
6063
6164 Stream <PayjoinModel > get expiredPayjoins => _expiredController.stream;
6265
66+ /// Stops the directory polling of one session — both the receiver
67+ /// request poll and the sender proposal poll, whichever exists for
68+ /// [payjoinId] . Called by the repository the moment a session resolves
69+ /// through a path the poll itself can't see (the plain-broadcast fallback
70+ /// landing on-chain): the poll only self-cancels on request/proposal
71+ /// found or expiry, so without this it kept firing until expiry and then
72+ /// raised a stale expired event for an already-completed session
73+ /// (observed live: a redundant second broadcast of the original
74+ /// transaction a minute after the session had already resolved).
75+ void stopPolling (String payjoinId) {
76+ _receiverTimers.remove (payjoinId)? .cancel ();
77+ _senderTimers.remove (payjoinId)? .cancel ();
78+ }
79+
80+ /// Cancels every polling timer and closes the event streams. Individual
81+ /// poll timers self-cancel on success/expiry, but a session that never
82+ /// resolves (a relay permanently down) would otherwise leave a
83+ /// [Timer.periodic] firing forever plus three unclosed broadcast
84+ /// controllers. The production singleton lives for the whole app session,
85+ /// but tests (and any future teardown) need a clean exit; the repository's
86+ /// own dispose delegates here. Idempotent: a second call is a no-op (closing
87+ /// an already-closed controller would otherwise throw).
88+ Future <void > dispose () async {
89+ if (_disposed) return ;
90+ _disposed = true ;
91+ for (final timer in _receiverTimers.values) {
92+ timer.cancel ();
93+ }
94+ _receiverTimers.clear ();
95+ for (final timer in _senderTimers.values) {
96+ timer.cancel ();
97+ }
98+ _senderTimers.clear ();
99+ _receiverPollsInFlight.clear ();
100+ _senderPollsInFlight.clear ();
101+ await _payjoinRequestedController.close ();
102+ await _proposalSentController.close ();
103+ await _expiredController.close ();
104+ }
105+
63106 Future <(OhttpKeys ?, String ?)> fetchOhttpKeyAndRelay ({
64107 required String payjoinDirectory,
65108 }) async {
@@ -274,6 +317,57 @@ class PdkPayjoinDatasource {
274317 return updatedModel;
275318 }
276319
320+ /// Formally cancels a receiver session that was declined below the
321+ /// configured minimum-receive-amount threshold (see
322+ /// PayjoinRepositoryImpl._processPayjoinRequest), and closes the
323+ /// underlying PDK session so it persists a terminal event.
324+ ///
325+ /// This replaces silently abandoning the session after broadcasting the
326+ /// original transaction out of band: without this, the PDK's own
327+ /// typestate machine never learns the session ended, so only our local
328+ /// DB flag (isAborted) stood between it and being replayed/resumed as if
329+ /// still pending. `cancel()` is available on every receive typestate that
330+ /// carries a fallback transaction (verified against the installed
331+ /// `payjoin` package's Dart bindings — `MaybeInputsOwned.cancel()` is one
332+ /// of them); calling it here transitions to `ReceiverPendingFallback` ,
333+ /// whose `close()` persists the closing `SessionEvent` via the
334+ /// persister. The original transaction itself is still broadcast by the
335+ /// caller from the already-captured, already-validated
336+ /// [PayjoinReceiverModel.originalTxBytes] — this method only concludes
337+ /// the PDK-side state machine to match that outcome.
338+ ///
339+ /// Always called right after `_pollReceiverOnce` has persisted a session
340+ /// at exactly the `MaybeInputsOwned` typestate (where
341+ /// `originalTxBytes` /`amountSat` first become available) — any other
342+ /// state means the session already progressed past the point a
343+ /// below-minimum decline is possible, or is already resolved.
344+ String declineReceiverSession (PayjoinReceiverModel receiverModel) {
345+ final persister = InMemoryJsonReceiverSessionPersister .fromJson (
346+ receiverModel.receiver,
347+ );
348+ final state = replayReceiverEventLog (persister: persister).state ();
349+ if (state is ! MaybeInputsOwnedReceiveSession ) {
350+ throw StateError (
351+ 'Cannot decline payjoin receiver ${receiverModel .id }: expected a '
352+ 'MaybeInputsOwned session, got $state ' ,
353+ );
354+ }
355+
356+ final pendingFallback = state.inner.cancel ().save (persister: persister);
357+ if (pendingFallback == null ) {
358+ // The session was already terminal (e.g. a race with another decline
359+ // path) — nothing further to persist, but not an error either.
360+ logger.log.info (
361+ 'Payjoin receiver ${receiverModel .id } was already resolved when '
362+ 'declining below minimum' ,
363+ );
364+ return persister.toJson ();
365+ }
366+
367+ pendingFallback.close ().save (persister: persister);
368+ return persister.toJson ();
369+ }
370+
277371 Future <({Monitor monitor, String psbt})> processReceiveSession ({
278372 required ReceiveSession state,
279373 required InMemoryJsonReceiverSessionPersister persister,
@@ -688,14 +782,18 @@ class PdkPayjoinDatasource {
688782 PayjoinSenderModel senderModel,
689783 Timer timer,
690784 ) async {
785+ // logRef, never the raw id in log lines/exception messages: a sender id
786+ // is the full BIP21 URI (address+amount+endpoint). The raw id is still
787+ // used as the internal map key below, which never reaches a log.
788+ final senderLogRef = Payjoin .logRefForId (senderModel.id);
691789 if (! _senderPollsInFlight.add (senderModel.id)) return ;
692- log ('[sender poll] checking for proposal for ${ senderModel . id } ' );
790+ log ('[sender poll] checking for proposal for $senderLogRef ' );
693791 try {
694792 // Local expiry backstop: don't rely solely on the PDK surfacing an
695793 // "expired" error — bound polling by the session's own expiry time.
696794 if (senderModel.isExpiryTimePassed) {
697795 throw PayjoinExpiredException (
698- 'Payjoin sender ${ senderModel . id } expiry time passed' ,
796+ 'Payjoin sender $senderLogRef expiry time passed' ,
699797 );
700798 }
701799 final persister = InMemoryJsonSenderSessionPersister .fromJson (
@@ -715,7 +813,7 @@ class PdkPayjoinDatasource {
715813 final proposalPsbt = await _getProposalPsbt (state.inner, persister);
716814 if (proposalPsbt == null ) return ;
717815
718- log ('[sender poll] proposal found for ${ senderModel . id } ' );
816+ log ('[sender poll] proposal found for $senderLogRef ' );
719817 final txId = (await BitcoinTx .fromPsbt (proposalPsbt)).txid;
720818 final updatedModel = senderModel.copyWith (
721819 sender: persister.toJson (),
@@ -730,13 +828,13 @@ class PdkPayjoinDatasource {
730828 _senderTimers.remove (senderModel.id);
731829 _proposalSentController.add (updatedModel);
732830 } on PayjoinExpiredException catch (e) {
733- logger.log.info ('[sender poll] expired for ${ senderModel . id } : $e ' );
831+ logger.log.info ('[sender poll] expired for $senderLogRef : $e ' );
734832 if (! timer.isActive) return ;
735833 timer.cancel ();
736834 _senderTimers.remove (senderModel.id);
737835 _expiredController.add (senderModel.copyWith (isExpired: true ));
738836 } catch (e) {
739- logger.log.info ('[sender poll] ${ senderModel . id } : $e ' );
837+ logger.log.info ('[sender poll] $senderLogRef : $e ' );
740838 } finally {
741839 _senderPollsInFlight.remove (senderModel.id);
742840 }
0 commit comments