Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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