Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
07acdcf
fix(transactions): preserve UTC when rounding the CSV export end date
ethicnology Jul 20, 2026
6907e30
feat(payjoin): let the watch use-case emit sender updates
ethicnology Jul 20, 2026
d556444
feat(wallet): Result-returning GetWalletTransactionUsecase
ethicnology Jul 20, 2026
ee947f2
feat(payjoin): datasource lifecycle and terminal-flag queries
ethicnology Jul 20, 2026
4aee114
feat(payjoin): session-resolution engine (watchers, fallback, resume)
ethicnology Jul 20, 2026
40ef27f
fix(send): resolve stuck payjoin send flow and gate on the setting (#…
ethicnology Jul 20, 2026
e96468b
feat(send): payjoin confirm indicator, fallback notice and countdown
ethicnology Jul 20, 2026
2bca53d
fix(receive): payjoin gating, navigation ownership and watcher guards
ethicnology Jul 20, 2026
f84b95f
feat(receive): terminal payjoin screen flavors and leave-on-completion
ethicnology Jul 20, 2026
60bac0b
feat(themes): add onSuccess pairing the existing success green
ethicnology Jul 20, 2026
6913402
feat(core): let TopBar host a custom trailing action widget
ethicnology Jul 20, 2026
31d5b2f
feat(receive): payjoin on/off toggle chip in the receive TopBar
ethicnology Jul 20, 2026
d1319da
feat(transactions): derive shown payjoin status from the broadcast tx
ethicnology Jul 20, 2026
73d996c
fix(transactions): refresh payjoin details and land on the broadcast tx
ethicnology Jul 20, 2026
110a6b2
feat(announcements): dismissible home announcement carousel
ethicnology Jul 21, 2026
175e8eb
fix: resume sweep expired-fallback senders too, not just receivers
i5hi Jul 21, 2026
e888b31
fix: resume-sweep sender coverage, terminal-state races, startup safety
i5hi Jul 21, 2026
0df2bbe
test(payjoin): stub fetchSenders in repository test setup
i5hi Jul 21, 2026
916dbab
fix(send): guard payjoin success against a sending-step race; fix do…
i5hi Jul 21, 2026
5323d33
Merge pull request #2479 from SatoshiPortal/pj/02-send
i5hi Jul 21, 2026
007700b
fix(receive): confirmed-balance eligibility, payjoin toggle race, and…
i5hi Jul 21, 2026
2989908
fix(receive): gate the actual session-creation check on confirmed bal…
i5hi Jul 21, 2026
30b74f1
Merge pull request #2480 from SatoshiPortal/pj/03-receive
i5hi Jul 21, 2026
4ff5f7e
Merge pull request #2481 from SatoshiPortal/pj/04-transactions
i5hi Jul 21, 2026
abcc731
refactor(announcements): resolve routes in ui and harden cubit
bullock Jul 22, 2026
f331f47
revert: restore dismissed_announcements for announcements feature
i5hi Jul 24, 2026
49ad3f4
Merge branch 'pj/01-core-engine' into payjoin-salvage
i5hi Jul 24, 2026
8893f01
Merge branch 'pj/06-csv-utc' into payjoin-salvage
i5hi Jul 24, 2026
09b5ec0
Merge branch 'payjoin-salvage' into pj/07-announcements
i5hi Jul 24, 2026
5ae462e
merge #2497 review fixes, dedupe duplicated files
i5hi Jul 24, 2026
68c037b
Merge pull request #2484 from SatoshiPortal/pj/07-announcements
i5hi Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ 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)
%% Instead, we note this in the documentation below

%% Feature-to-feature dependencies (extracted from draw.io diagram)
ADDRESS_MGMT --> LABELS
ANNOUNCEMENTS --> SETTINGS
ANNOUNCEMENTS --> TX_HISTORY
APP_STARTUP --> WALLETS
AUTOSWAPS --> TRANSFER
BIP85 --> SECRETS
Expand All @@ -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
Expand All @@ -84,6 +90,7 @@ graph TB
SEND --> NETWORK
SEND --> PAYJOIN
SEND --> SWAPS
SEND --> TX_HISTORY
SEND --> UTXO_MGMT
SEND --> WALLETS
SETTINGS --> CORE
Expand All @@ -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
Expand Down
11 changes: 2 additions & 9 deletions integration_test/payjoin_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -109,10 +108,6 @@ Future<void> main({bool isInitialized = false}) async {

setUpAll(() async {
await locator<SetEnvironmentUsecase>().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<SetPayjoinEnabledUsecase>().execute(true);

// Drain any persisted payjoin state so the test starts clean. Ongoing
// payjoins left behind by a previous (possibly crashed) run keep their
Expand Down Expand Up @@ -249,8 +244,7 @@ Future<void> 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
Expand Down Expand Up @@ -336,8 +330,7 @@ Future<void> 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,
Expand Down
34 changes: 30 additions & 4 deletions lib/core/payjoin/data/datasources/local_payjoin_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,15 @@ class LocalPayjoinDatasource {
Expression<bool> 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) {
Expand All @@ -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) {
Expand All @@ -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<List<PayjoinModel>> 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 [
Expand All @@ -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();
Expand All @@ -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();
Expand Down
108 changes: 103 additions & 5 deletions lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,6 +44,8 @@ class PdkPayjoinDatasource {
final Set<String> _receiverPollsInFlight = {};
final Set<String> _senderPollsInFlight = {};

bool _disposed = false;

PdkPayjoinDatasource({
this._payjoinDirectoryUrl = PayjoinConstants.directoryUrl,
required this._dio,
Expand All @@ -60,6 +63,46 @@ class PdkPayjoinDatasource {

Stream<PayjoinModel> 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<void> 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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(),
Expand All @@ -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);
}
Expand Down
Loading