Skip to content
Merged
35 changes: 32 additions & 3 deletions lib/core/payjoin/data/models/payjoin_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ sealed class PayjoinModel with _$PayjoinModel {
String? txId,
@Default(false) bool isExpired,
@Default(false) bool isCompleted,
@Default(false) bool isAborted,
}) = PayjoinReceiverModel;
const factory PayjoinModel.sender({
required String uri,
Expand All @@ -42,12 +43,19 @@ sealed class PayjoinModel with _$PayjoinModel {
String? txId,
@Default(false) bool isExpired,
@Default(false) bool isCompleted,
@Default(false) bool isAborted,
}) = PayjoinSenderModel;
const PayjoinModel._();

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

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

factory PayjoinModel.fromSenderTable(PayjoinSenderRow table) =>
Expand All @@ -79,6 +90,9 @@ sealed class PayjoinModel with _$PayjoinModel {
expireAfterSec: table.expireAfterSec,
proposalPsbt: table.proposalPsbt,
txId: table.txId,
isExpired: table.isExpired,
isCompleted: table.isCompleted,
isAborted: table.isAborted,
);

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

// isCompleted (real payjoin broadcast) and isAborted (we broadcast the
// original instead) are normally set on mutually exclusive paths, but
// isCompleted is checked first regardless: the one path that can set both
// is a genuine on-chain race where our payjoin transaction confirms after
// the fallback watcher already marked the session aborted — see
// PayjoinRepositoryImpl._broadcastPsbt, which logs that case. The real
// payjoin is then the true outcome, so completed wins here.
//
// Note the txId is cleared when isAborted is set (see
// _broadcastOriginalTransaction / _onOriginalTransactionSeen): that is
// display hygiene (a stale, never-broadcast payjoin txid must not surface),
// NOT how the status is derived — the status comes purely from these flags.
PayjoinStatus get status => switch (this) {
PayjoinReceiverModel(:final originalTxBytes) =>
isCompleted
? PayjoinStatus.completed
: isAborted
? PayjoinStatus.aborted
: isExpired
? PayjoinStatus.expired
: proposalPsbt != null
Expand All @@ -105,16 +133,15 @@ sealed class PayjoinModel with _$PayjoinModel {
PayjoinSenderModel() =>
isCompleted
? PayjoinStatus.completed
: isAborted
? PayjoinStatus.aborted
: isExpired
? PayjoinStatus.expired
: proposalPsbt != null
? PayjoinStatus.proposed
: PayjoinStatus.requested,
};

bool get isOngoing =>
status == PayjoinStatus.requested || status == PayjoinStatus.proposed;

Payjoin toEntity() {
switch (this) {
case final PayjoinReceiverModel receiver:
Expand Down Expand Up @@ -168,6 +195,7 @@ extension PayjoinReceiverSqlite on PayjoinReceiverModel {
txId: txId,
isExpired: isExpired,
isCompleted: isCompleted,
isAborted: isAborted,
);
}

Expand All @@ -186,5 +214,6 @@ extension PayjoinSenderSqlite on PayjoinSenderModel {
txId: txId,
isExpired: isExpired,
isCompleted: isCompleted,
isAborted: isAborted,
);
}
74 changes: 72 additions & 2 deletions lib/core/payjoin/domain/entity/payjoin.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'payjoin.freezed.dart';

enum PayjoinStatus { started, requested, proposed, completed, expired }
/// - [completed]: a real payjoin proposal was negotiated and broadcast.
/// - [aborted]: no real payjoin happened — WE broadcast the original
/// transaction instead (below-minimum decline, manual "send without
/// payjoin", or expiry with an original available). The payment still
/// landed, just as a plain transaction; naming the outcome (aborted) not
/// the mechanism (a fallback broadcast) keeps this legible to the user
/// without implying anything went wrong with their payment.
/// - [expired]: the session died with nothing broadcast by us.
enum PayjoinStatus { started, requested, proposed, completed, aborted, expired }

@freezed
sealed class Payjoin with _$Payjoin {
Expand Down Expand Up @@ -42,9 +52,69 @@ sealed class Payjoin with _$Payjoin {
PayjoinSender(:final uri) => uri,
};

/// Privacy-safe identifier for log lines. A sender's [id] IS the full
/// BIP21 URI — address, amount and payjoin endpoint — which must never be
/// logged (logs are user-shareable and SEVERE records reach Sentry). Hash
/// it to the same opaque 16-hex-char shape as a receiver id, which is
/// already a sha256 prefix of the pjUri and passes through unchanged so
/// existing logs still correlate with stored sessions. The URI's `pj`/`rk`
/// params make it high-entropy, so an unsalted hash is irreversible while
/// staying stable across restarts (resumed-session logs keep correlating).
String get logRef => switch (this) {
PayjoinReceiver(:final id) => id,
PayjoinSender(:final uri) => logRefForId(uri),
};

/// [logRef] for a raw session id string, for callers that only have the id
/// (not the entity) — e.g. the repository's watcher machinery, keyed by
/// payjoin id. A receiver id is already an opaque 16-hex-char sha256 prefix
/// (passes through unchanged so logs still correlate); anything else is a
/// sender's BIP21 URI (address+amount+endpoint) and MUST be hashed to the
/// same shape before it can reach a log line.
static String logRefForId(String id) {
final isOpaqueReceiverId =
id.length == 16 && RegExp(r'^[0-9a-f]{16}$').hasMatch(id);
if (isOpaqueReceiverId) return id;
return sha256.convert(utf8.encode(id)).toString().substring(0, 16);
}

bool get isCompleted => status == PayjoinStatus.completed;
bool get isAborted => status == PayjoinStatus.aborted;
bool get isExpired => status == PayjoinStatus.expired;
bool get isOngoing => !isCompleted && !isExpired;
bool get isOngoing => !isCompleted && !isAborted && !isExpired;

/// Whether a manual "broadcast the original transaction" action would
/// actually do anything right now, as opposed to silently no-op'ing (see
/// PayjoinRepositoryImpl.tryBroadcastOriginalTransaction's guard, which
/// this mirrors exactly). This is the single source of truth for BOTH
/// halves of that feature — a manual-broadcast button's visibility and
/// the action it triggers — so the two can never drift out of sync and
/// show a button that would just do nothing when tapped (observed live:
/// a stale-looking sender button re-broadcast an already-completed
/// session).
///
/// Role-specific, not just `proposalPsbt == null` (which stays true
/// forever once a proposal is sent, even past a terminal state):
/// - Receiver: once a proposal is SENT, the SENDER owns finalizing it for
/// as long as that takes — there is no dead-end here that would ever
/// need a manual retry.
/// - Sender: once a proposal is RECEIVED, the repository's own handler
/// owns signing/broadcasting it, but if that AND its own internal
/// fallback both fail, the session ends up isExpired with proposalPsbt
/// still set and nothing left to retry it automatically — a manual
/// retry must still be possible there.
bool get canManuallyBroadcastOriginal {
if (isCompleted || isAborted) return false;
return switch (this) {
// originalTxBytes != null: a receiver still in `started` (no request
// received yet) has nothing to broadcast — broadcasting would hit a
// null originalTxBytes. The button only makes sense once the sender's
// original transaction is in hand.
PayjoinReceiver(:final originalTxBytes) =>
originalTxBytes != null && proposalPsbt == null,
PayjoinSender() => proposalPsbt == null || isExpired,
};
}

// Currently payjoin is always bitcoin, not liquid
bool get isBitcoin => true;
Expand Down
19 changes: 19 additions & 0 deletions lib/core/settings/data/settings_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ class SettingsDatasource {
);
}

Future<void> setPayjoinEnabled(bool enabled) async {
await _sqlite.managers.settings.update(
(f) => f(id: const Value(1), payjoinEnabled: Value(enabled)),
);
}

Future<void> setPayjoinMinAmountSat(int amountSat) async {
await _sqlite.managers.settings.update(
(f) => f(id: const Value(1), payjoinMinAmountSat: Value(amountSat)),
);
}

Future<void> setPayjoinExpireAfterSec(int expireAfterSec) async {
await _sqlite.managers.settings.update(
(f) =>
f(id: const Value(1), payjoinExpireAfterSec: Value(expireAfterSec)),
);
}

Future<void> setExchangeTestnetBasicAuth({
String? username,
String? password,
Expand Down
12 changes: 12 additions & 0 deletions lib/core/settings/data/settings_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class SettingsModel {
final bool isErrorReportingEnabled;
final String? exchangeTestnetBasicAuthUsername;
final String? exchangeTestnetBasicAuthPassword;
final bool payjoinEnabled;
final int payjoinMinAmountSat;
final int payjoinExpireAfterSec;

const SettingsModel({
required this.id,
Expand All @@ -32,6 +35,9 @@ class SettingsModel {
required this.isErrorReportingEnabled,
this.exchangeTestnetBasicAuthUsername,
this.exchangeTestnetBasicAuthPassword,
required this.payjoinEnabled,
required this.payjoinMinAmountSat,
required this.payjoinExpireAfterSec,
});

SettingsRow toSqlite() {
Expand All @@ -50,6 +56,9 @@ class SettingsModel {
isErrorReportingEnabled: isErrorReportingEnabled,
exchangeTestnetBasicAuthUsername: exchangeTestnetBasicAuthUsername,
exchangeTestnetBasicAuthPassword: exchangeTestnetBasicAuthPassword,
payjoinEnabled: payjoinEnabled,
payjoinMinAmountSat: payjoinMinAmountSat,
payjoinExpireAfterSec: payjoinExpireAfterSec,
);
}

Expand All @@ -69,6 +78,9 @@ class SettingsModel {
isErrorReportingEnabled: row.isErrorReportingEnabled,
exchangeTestnetBasicAuthUsername: row.exchangeTestnetBasicAuthUsername,
exchangeTestnetBasicAuthPassword: row.exchangeTestnetBasicAuthPassword,
payjoinEnabled: row.payjoinEnabled,
payjoinMinAmountSat: row.payjoinMinAmountSat,
payjoinExpireAfterSec: row.payjoinExpireAfterSec,
);
}
}
35 changes: 34 additions & 1 deletion lib/core/settings/data/settings_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@ import 'package:bb_mobile/core/settings/data/settings_model.dart';
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart'
as domain;
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/utils/report.dart';

class SettingsRepository implements domain.SettingsRepository {
final SettingsDatasource _settingsDatasource;
final StreamController<String> _currencyChangeController;
final StreamController<bool> _payjoinEnabledChangeController;

SettingsRepository({required this._settingsDatasource})
: _currencyChangeController = StreamController<String>.broadcast();
: _currencyChangeController = StreamController<String>.broadcast(),
_payjoinEnabledChangeController = StreamController<bool>.broadcast();

@override
Stream<String> get currencyChangeStream => _currencyChangeController.stream;

@override
Stream<bool> get payjoinEnabledChangeStream =>
_payjoinEnabledChangeController.stream;

@override
Future<void> close() async {
await _currencyChangeController.close();
await _payjoinEnabledChangeController.close();
}

@override
Expand All @@ -38,6 +46,9 @@ class SettingsRepository implements domain.SettingsRepository {
bool isErrorReportingEnabled = false,
String? exchangeTestnetBasicAuthUsername,
String? exchangeTestnetBasicAuthPassword,
bool isPayjoinEnabled = false,
int payjoinMinAmountSat = PayjoinConstants.defaultMinAmountSat,
int payjoinExpireAfterSec = PayjoinConstants.defaultExpireAfterSec,
}) async {
await _settingsDatasource.store(
SettingsModel(
Expand All @@ -55,6 +66,9 @@ class SettingsRepository implements domain.SettingsRepository {
isErrorReportingEnabled: isErrorReportingEnabled,
exchangeTestnetBasicAuthUsername: exchangeTestnetBasicAuthUsername,
exchangeTestnetBasicAuthPassword: exchangeTestnetBasicAuthPassword,
payjoinEnabled: isPayjoinEnabled,
payjoinMinAmountSat: payjoinMinAmountSat,
payjoinExpireAfterSec: payjoinExpireAfterSec,
),
);
}
Expand All @@ -77,6 +91,9 @@ class SettingsRepository implements domain.SettingsRepository {
isErrorReportingEnabled: s.isErrorReportingEnabled,
exchangeTestnetBasicAuthUsername: s.exchangeTestnetBasicAuthUsername,
exchangeTestnetBasicAuthPassword: s.exchangeTestnetBasicAuthPassword,
isPayjoinEnabled: s.payjoinEnabled,
payjoinMinAmountSat: s.payjoinMinAmountSat,
payjoinExpireAfterSec: s.payjoinExpireAfterSec,
);
}

Expand Down Expand Up @@ -131,6 +148,22 @@ class SettingsRepository implements domain.SettingsRepository {
await _settingsDatasource.setThemeMode(themeMode);
}

@override
Future<void> setPayjoinEnabled(bool enabled) async {
await _settingsDatasource.setPayjoinEnabled(enabled);
_payjoinEnabledChangeController.add(enabled);
}

@override
Future<void> setPayjoinMinAmountSat(int amountSat) async {
await _settingsDatasource.setPayjoinMinAmountSat(amountSat);
}

@override
Future<void> setPayjoinExpireAfterSec(int expireAfterSec) async {
await _settingsDatasource.setPayjoinExpireAfterSec(expireAfterSec);
}

@override
Future<void> setExchangeTestnetBasicAuth({
String? username,
Expand Down
15 changes: 15 additions & 0 deletions lib/core/settings/domain/repositories/settings_repository.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import 'dart:async';

import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/utils/constants.dart';

abstract class SettingsRepository {
Stream<String> get currencyChangeStream;

/// Emits the new value every time [setPayjoinEnabled] persists a change,
/// so a live listener (the receive flow) can react to the setting being
/// flipped elsewhere in the app without needing to re-enter its screen.
Stream<bool> get payjoinEnabledChangeStream;

Future<void> close();

Future<void> store({
Expand All @@ -22,6 +28,9 @@ abstract class SettingsRepository {
bool isErrorReportingEnabled = false,
String? exchangeTestnetBasicAuthUsername,
String? exchangeTestnetBasicAuthPassword,
bool isPayjoinEnabled = false,
int payjoinMinAmountSat = PayjoinConstants.defaultMinAmountSat,
int payjoinExpireAfterSec = PayjoinConstants.defaultExpireAfterSec,
});

Future<SettingsEntity> fetch();
Expand All @@ -48,6 +57,12 @@ abstract class SettingsRepository {

Future<void> setErrorReportingEnabled(bool enabled);

Future<void> setPayjoinEnabled(bool enabled);

Future<void> setPayjoinMinAmountSat(int amountSat);

Future<void> setPayjoinExpireAfterSec(int expireAfterSec);

Future<void> setExchangeTestnetBasicAuth({
String? username,
String? password,
Expand Down
Loading