Skip to content

Commit 5a55bfd

Browse files
committed
Merge remote-tracking branch 'origin/develop' into fix/reproducibility-audit-findings
2 parents 6c7b477 + 6caa9f1 commit 5a55bfd

185 files changed

Lines changed: 24897 additions & 450 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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@ graph TB
4545
PAY[Pay]
4646
BUY[Buy]
4747
COINS[Coins / UTXOs]
48+
ANNOUNCEMENTS[Announcements]
49+
CONSOLIDATION[Consolidation]
4850
4951
%% Dependencies to Core (all features depend on Core, but showing it explicitly would clutter the diagram)
5052
%% Instead, we note this in the documentation below
5153
5254
%% Feature-to-feature dependencies (extracted from draw.io diagram)
5355
ADDRESS_MGMT --> LABELS
56+
ANNOUNCEMENTS --> SETTINGS
57+
ANNOUNCEMENTS --> TX_HISTORY
5458
APP_STARTUP --> WALLETS
5559
AUTOSWAPS --> TRANSFER
5660
BIP85 --> SECRETS
@@ -72,28 +76,35 @@ graph TB
7276
LABELS --> CORE
7377
PAY --> RECIPIENTS
7478
PAYJOIN --> UTXO_MGMT
79+
PAYJOIN --> LABELS
7580
PIN_CODE --> CORE
7681
RECEIVE --> PAYJOIN
82+
RECEIVE --> SETTINGS
7783
RECEIVE --> SWAPS
84+
RECEIVE --> TX_HISTORY
7885
RECIPIENTS --> EXCHANGE
7986
SECRETS --> CORE
8087
SELL --> EXCHANGE
88+
SEND --> CONSOLIDATION
8189
SEND --> FEES
8290
SEND --> NETWORK
8391
SEND --> PAYJOIN
8492
SEND --> SWAPS
93+
SEND --> TX_HISTORY
8594
SEND --> UTXO_MGMT
8695
SEND --> WALLETS
8796
SETTINGS --> CORE
8897
SWAPS --> UTXO_MGMT
8998
TOR --> CORE
99+
TRANSFER --> CONSOLIDATION
90100
TRANSFER --> SEND
91101
TRANSFER --> RECEIVE
92102
TX_HISTORY --> PAYJOIN
93103
TX_HISTORY --> WALLETS
94104
UTXO_MGMT --> LABELS
95105
UTXO_MGMT --> WALLETS
96106
WALLETS --> BIP85
107+
WALLETS --> CONSOLIDATION
97108
WALLETS --> HW_WALLETS
98109
WALLETS --> NETWORK
99110
WALLETS --> SECRETS
@@ -105,7 +116,7 @@ graph TB
105116
classDef featureStyle fill:#1a202c,stroke:#2d3748,stroke-width:2px,color:#e2e8f0
106117
107118
class CORE coreStyle
108-
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 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
109120
```
110121

111122
## About Package Dependency Diagrams

ios/Runner.xcodeproj/project.pbxproj

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -481,11 +481,11 @@
481481
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
482482
CLANG_ENABLE_MODULES = YES;
483483
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
484-
CURRENT_PROJECT_VERSION = 191;
484+
CURRENT_PROJECT_VERSION = 200;
485485
DEVELOPMENT_TEAM = BX99T32YGS;
486486
ENABLE_BITCODE = NO;
487-
FLUTTER_BUILD_NAME = 6.12.0;
488-
FLUTTER_BUILD_NUMBER = 191;
487+
FLUTTER_BUILD_NAME = 6.13.0;
488+
FLUTTER_BUILD_NUMBER = 200;
489489
INFOPLIST_FILE = Runner/Info.plist;
490490
INFOPLIST_KEY_CFBundleDisplayName = BULL;
491491
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -494,7 +494,7 @@
494494
"$(inherited)",
495495
"@executable_path/Frameworks",
496496
);
497-
MARKETING_VERSION = 6.12.0;
497+
MARKETING_VERSION = 6.13.0;
498498
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
499499
PRODUCT_NAME = "$(TARGET_NAME)";
500500
STRIP_STYLE = "non-global";
@@ -674,11 +674,11 @@
674674
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
675675
CLANG_ENABLE_MODULES = YES;
676676
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
677-
CURRENT_PROJECT_VERSION = 191;
677+
CURRENT_PROJECT_VERSION = 200;
678678
DEVELOPMENT_TEAM = BX99T32YGS;
679679
ENABLE_BITCODE = NO;
680-
FLUTTER_BUILD_NAME = 6.12.0;
681-
FLUTTER_BUILD_NUMBER = 191;
680+
FLUTTER_BUILD_NAME = 6.13.0;
681+
FLUTTER_BUILD_NUMBER = 200;
682682
INFOPLIST_FILE = Runner/Info.plist;
683683
INFOPLIST_KEY_CFBundleDisplayName = BULL;
684684
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -687,7 +687,7 @@
687687
"$(inherited)",
688688
"@executable_path/Frameworks",
689689
);
690-
MARKETING_VERSION = 6.12.0;
690+
MARKETING_VERSION = 6.13.0;
691691
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
692692
PRODUCT_NAME = "$(TARGET_NAME)";
693693
STRIP_STYLE = "non-global";
@@ -705,11 +705,11 @@
705705
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
706706
CLANG_ENABLE_MODULES = YES;
707707
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
708-
CURRENT_PROJECT_VERSION = 191;
708+
CURRENT_PROJECT_VERSION = 200;
709709
DEVELOPMENT_TEAM = BX99T32YGS;
710710
ENABLE_BITCODE = NO;
711-
FLUTTER_BUILD_NAME = 6.12.0;
712-
FLUTTER_BUILD_NUMBER = 191;
711+
FLUTTER_BUILD_NAME = 6.13.0;
712+
FLUTTER_BUILD_NUMBER = 200;
713713
INFOPLIST_FILE = Runner/Info.plist;
714714
INFOPLIST_KEY_CFBundleDisplayName = BULL;
715715
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
@@ -718,7 +718,7 @@
718718
"$(inherited)",
719719
"@executable_path/Frameworks",
720720
);
721-
MARKETING_VERSION = 6.12.0;
721+
MARKETING_VERSION = 6.13.0;
722722
PRODUCT_BUNDLE_IDENTIFIER = com.bullbitcoin.app;
723723
PRODUCT_NAME = "$(TARGET_NAME)";
724724
STRIP_STYLE = "non-global";

ios/Runner/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
<string>We need access to Bluetooth in order to connect to your hardware wallet when needed</string>
6060
<key>NSLocationWhenInUseUsageDescription</key>
6161
<string>The app itself never collects your location. Location access is only requested if a website opened in the in-app browser asks for it</string>
62+
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
63+
<string>The app itself never collects your location. Location access is only requested if a website opened in the in-app browser asks for it</string>
6264
<key>UIApplicationSupportsIndirectInputEvents</key>
6365
<true/>
6466
<key>UIDesignRequiresCompatibility</key>

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
}

0 commit comments

Comments
 (0)