Skip to content

Commit 6caa9f1

Browse files
authored
Merge pull request #2499 from SatoshiPortal/payjoin-upgrade
Payjoin Fixes (Requires review)
2 parents 866fd7c + 7c1d1f7 commit 6caa9f1

153 files changed

Lines changed: 22594 additions & 414 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

FEATURES.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ graph TB
4545
PAY[Pay]
4646
BUY[Buy]
4747
COINS[Coins / UTXOs]
48+
ANNOUNCEMENTS[Announcements]
4849
CONSOLIDATION[Consolidation]
4950
5051
%% Dependencies to Core (all features depend on Core, but showing it explicitly would clutter the diagram)
5152
%% Instead, we note this in the documentation below
5253
5354
%% Feature-to-feature dependencies (extracted from draw.io diagram)
5455
ADDRESS_MGMT --> LABELS
56+
ANNOUNCEMENTS --> SETTINGS
57+
ANNOUNCEMENTS --> TX_HISTORY
5558
APP_STARTUP --> WALLETS
5659
AUTOSWAPS --> TRANSFER
5760
BIP85 --> SECRETS
@@ -73,9 +76,12 @@ graph TB
7376
LABELS --> CORE
7477
PAY --> RECIPIENTS
7578
PAYJOIN --> UTXO_MGMT
79+
PAYJOIN --> LABELS
7680
PIN_CODE --> CORE
7781
RECEIVE --> PAYJOIN
82+
RECEIVE --> SETTINGS
7883
RECEIVE --> SWAPS
84+
RECEIVE --> TX_HISTORY
7985
RECIPIENTS --> EXCHANGE
8086
SECRETS --> CORE
8187
SELL --> EXCHANGE
@@ -84,6 +90,7 @@ graph TB
8490
SEND --> NETWORK
8591
SEND --> PAYJOIN
8692
SEND --> SWAPS
93+
SEND --> TX_HISTORY
8794
SEND --> UTXO_MGMT
8895
SEND --> WALLETS
8996
SETTINGS --> CORE
@@ -109,7 +116,7 @@ graph TB
109116
classDef featureStyle fill:#1a202c,stroke:#2d3748,stroke-width:2px,color:#e2e8f0
110117
111118
class CORE coreStyle
112-
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
119+
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
113120
```
114121

115122
## About Package Dependency Diagrams

lib/core/payjoin/data/datasources/local_payjoin_datasource.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,15 @@ class LocalPayjoinDatasource {
5858
Expression<bool> expr = const Constant(true); // identity
5959

6060
if (onlyUnfinished) {
61+
// isAborted is a terminal outcome too (we already broadcast the
62+
// original in its place) — excluded here for the same reason
63+
// isCompleted/isExpired are, otherwise an aborted session would
64+
// keep being "resumed" on every app start.
6165
expr =
62-
expr & row.isExpired.equals(false) & row.isCompleted.equals(false);
66+
expr &
67+
row.isExpired.equals(false) &
68+
row.isCompleted.equals(false) &
69+
row.isAborted.equals(false);
6370
}
6471

6572
if (walletId != null) {
@@ -78,7 +85,10 @@ class LocalPayjoinDatasource {
7885

7986
if (onlyUnfinished) {
8087
expr =
81-
expr & row.isExpired.equals(false) & row.isCompleted.equals(false);
88+
expr &
89+
row.isExpired.equals(false) &
90+
row.isCompleted.equals(false) &
91+
row.isAborted.equals(false);
8292
}
8393

8494
if (walletId != null) {
@@ -103,10 +113,24 @@ class LocalPayjoinDatasource {
103113
];
104114
}
105115

116+
/// Fetches the payjoin session(s) a transaction id belongs to, matching
117+
/// BOTH the payjoin transaction id and the original transaction id. The
118+
/// original matters as much as the payjoin one: an aborted session (we
119+
/// broadcast the original instead of completing a real payjoin — see
120+
/// PayjoinStatus.aborted) has no [txId] at all, so the transaction that
121+
/// actually hit the chain IS the original — matching only [txId] made
122+
/// that transaction's details lose its payjoin context entirely, hiding
123+
/// the very "aborted" outcome the status exists to communicate. The
124+
/// transactions LIST already joins on both ids
125+
/// (GetTransactionsUsecase); this keeps the details path consistent.
106126
Future<List<PayjoinModel>> fetchByTxId(String txId) async {
107127
final (receivers, senders) = await (
108-
_db.managers.payjoinReceivers.filter((f) => f.txId(txId)).get(),
109-
_db.managers.payjoinSenders.filter((f) => f.txId(txId)).get(),
128+
_db.managers.payjoinReceivers
129+
.filter((f) => f.txId(txId) | f.originalTxId(txId))
130+
.get(),
131+
_db.managers.payjoinSenders
132+
.filter((f) => f.txId(txId) | f.originalTxId(txId))
133+
.get(),
110134
).wait;
111135

112136
return [
@@ -124,6 +148,7 @@ class LocalPayjoinDatasource {
124148
receivers = await receiversTable
125149
.filter((f) => f.isExpired(false))
126150
.filter((f) => f.isCompleted(false))
151+
.filter((f) => f.isAborted(false))
127152
.get();
128153
} else {
129154
receivers = await receiversTable.get();
@@ -147,6 +172,7 @@ class LocalPayjoinDatasource {
147172
senders = await sendersTable
148173
.filter((f) => f.isExpired(false))
149174
.filter((f) => f.isCompleted(false))
175+
.filter((f) => f.isAborted(false))
150176
.get();
151177
} else {
152178
senders = await sendersTable.get();

lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'dart:developer';
55
import 'package:bb_mobile/core/errors/bull_exception.dart';
66
import 'package:bb_mobile/core/payjoin/data/models/payjoin_input_pair_model.dart';
77
import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart';
8+
import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart' show Payjoin;
89
import 'package:bb_mobile/core/utils/bitcoin_tx.dart';
910
import 'package:bb_mobile/core/utils/constants.dart';
1011
import '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
}

lib/core/payjoin/data/models/payjoin_model.dart

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ sealed class PayjoinModel with _$PayjoinModel {
2727
String? txId,
2828
@Default(false) bool isExpired,
2929
@Default(false) bool isCompleted,
30+
@Default(false) bool isAborted,
3031
}) = PayjoinReceiverModel;
3132
const factory PayjoinModel.sender({
3233
required String uri,
@@ -42,12 +43,19 @@ sealed class PayjoinModel with _$PayjoinModel {
4243
String? txId,
4344
@Default(false) bool isExpired,
4445
@Default(false) bool isCompleted,
46+
@Default(false) bool isAborted,
4547
}) = PayjoinSenderModel;
4648
const PayjoinModel._();
4749

4850
factory PayjoinModel.fromJson(Map<String, dynamic> json) =>
4951
_$PayjoinModelFromJson(json);
5052

53+
// NOTE (fixed pre-existing bug): earlier versions of these factories never
54+
// mapped isExpired/isCompleted back from the row, so every re-fetch of a
55+
// session (app restart, transaction-details re-open, getPayjoins) silently
56+
// reset its displayed status to "never resolved" no matter what was
57+
// actually persisted. isAborted must be included here too, or the same
58+
// bug reappears for the new field the moment it's added.
5159
factory PayjoinModel.fromReceiverTable(PayjoinReceiverRow table) =>
5260
PayjoinReceiverModel(
5361
id: table.id,
@@ -64,6 +72,9 @@ sealed class PayjoinModel with _$PayjoinModel {
6472
amountSat: table.amountSat,
6573
proposalPsbt: table.proposalPsbt,
6674
txId: table.txId,
75+
isExpired: table.isExpired,
76+
isCompleted: table.isCompleted,
77+
isAborted: table.isAborted,
6778
);
6879

6980
factory PayjoinModel.fromSenderTable(PayjoinSenderRow table) =>
@@ -79,6 +90,9 @@ sealed class PayjoinModel with _$PayjoinModel {
7990
expireAfterSec: table.expireAfterSec,
8091
proposalPsbt: table.proposalPsbt,
8192
txId: table.txId,
93+
isExpired: table.isExpired,
94+
isCompleted: table.isCompleted,
95+
isAborted: table.isAborted,
8296
);
8397

8498
int get expiresAt => createdAt + expireAfterSec;
@@ -91,10 +105,24 @@ sealed class PayjoinModel with _$PayjoinModel {
91105
PayjoinSenderModel(:final uri) => uri,
92106
};
93107

108+
// isCompleted (real payjoin broadcast) and isAborted (we broadcast the
109+
// original instead) are normally set on mutually exclusive paths, but
110+
// isCompleted is checked first regardless: the one path that can set both
111+
// is a genuine on-chain race where our payjoin transaction confirms after
112+
// the fallback watcher already marked the session aborted — see
113+
// PayjoinRepositoryImpl._broadcastPsbt, which logs that case. The real
114+
// payjoin is then the true outcome, so completed wins here.
115+
//
116+
// Note the txId is cleared when isAborted is set (see
117+
// _broadcastOriginalTransaction / _onOriginalTransactionSeen): that is
118+
// display hygiene (a stale, never-broadcast payjoin txid must not surface),
119+
// NOT how the status is derived — the status comes purely from these flags.
94120
PayjoinStatus get status => switch (this) {
95121
PayjoinReceiverModel(:final originalTxBytes) =>
96122
isCompleted
97123
? PayjoinStatus.completed
124+
: isAborted
125+
? PayjoinStatus.aborted
98126
: isExpired
99127
? PayjoinStatus.expired
100128
: proposalPsbt != null
@@ -105,16 +133,15 @@ sealed class PayjoinModel with _$PayjoinModel {
105133
PayjoinSenderModel() =>
106134
isCompleted
107135
? PayjoinStatus.completed
136+
: isAborted
137+
? PayjoinStatus.aborted
108138
: isExpired
109139
? PayjoinStatus.expired
110140
: proposalPsbt != null
111141
? PayjoinStatus.proposed
112142
: PayjoinStatus.requested,
113143
};
114144

115-
bool get isOngoing =>
116-
status == PayjoinStatus.requested || status == PayjoinStatus.proposed;
117-
118145
Payjoin toEntity() {
119146
switch (this) {
120147
case final PayjoinReceiverModel receiver:
@@ -168,6 +195,7 @@ extension PayjoinReceiverSqlite on PayjoinReceiverModel {
168195
txId: txId,
169196
isExpired: isExpired,
170197
isCompleted: isCompleted,
198+
isAborted: isAborted,
171199
);
172200
}
173201

@@ -186,5 +214,6 @@ extension PayjoinSenderSqlite on PayjoinSenderModel {
186214
txId: txId,
187215
isExpired: isExpired,
188216
isCompleted: isCompleted,
217+
isAborted: isAborted,
189218
);
190219
}

0 commit comments

Comments
 (0)