implement-payjoin (#1949)

* Initial Payjoin

* Initial Payjoin

* More payjoin stuff

* Minor fixes

* Minor fixes

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Fix minor bug causes by data inconsistency in the btc utxos

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Initial Payjoin

* Initial Payjoin

* More payjoin stuff

* Minor fixes

* Minor fixes

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Fix minor bug causes by data inconsistency in the btc utxos

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Minor cleanup

* Fix Rebase issues

* Move PJ Receiver to isolate

* Add Payjoin Setting

* Payjoin Sender are now isolated

* Added Payjoin sessions to tx overview. Fix Fee issue with payjoin

* Clean up code

* Fix taproot for payjoin

* Fix CI Errors

* Add Payjoin UI elements and details page

* Add Payjoin UI elements and details page

* Fix Translations

* feat: Detect Payjoin URIs in pasted text and show to the User sending Payjoin

* feat: rename pjUri to payjoinURI for more code clarity

* Update res/values/strings_pl.arb

Co-authored-by: cyan <cyjan@mrcyjanek.net>

* Update cw_bitcoin/lib/payjoin/manager.dart

Co-authored-by: cyan <cyjan@mrcyjanek.net>

* Update cw_bitcoin/lib/payjoin/manager.dart

Co-authored-by: cyan <cyjan@mrcyjanek.net>

* feat: Disable Payjoin per default

* feat: Disable Payjoin fully if disabled or no Inputs available

* feat: Resume Payjoin if app comes back to foreground

* chore: Revert overly aggressive code formats

* feat: show correct Payjoin amount for receivers

* feat: Improved payjoin status

* feat: Show payjoin errors on payjoin details screen

* deps: update flutter to 3.27.4

* feat: Revert localisations

* bug: Remove duplicate transaction id on payjoin details

* style: remove double await in payjoin sender

* refactor(cw_bitcoin): Refactor method signatures and convert constructor to factory

* refactor(cw_bitcoin): Refactor wallet service and PSBT signer for cleaner code

Removed unnecessary `CakeHive` dependency and refactored `BitcoinWallet` initialization to use `payjoinSessionSource`. Improved code readability in `PsbtSigner` by reformatting lines and simplifying constructor methods for `UtxoWithPrivateKey`.

* fix: Resume Payjoin Sessions and load PJUri after sleep

* feat: Add "Copy Payjoin URL button" to receive screen

* fix: Add "Payjoin enabled"-Box below QR Code on the receive screen

* fix: Set payjoin_enabled color to black independent of the theme

* refactor: Payjoin session management and cleanup unused code.

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
Co-authored-by: cyan <cyjan@mrcyjanek.net>
This commit is contained in:
Konstantin Ullrich
2025-05-12 20:33:14 +03:00
committed by GitHub
co-authored by cyan Omar Hatem
parent 4a08e18f00
commit 82e3ebf4fa
84 changed files with 2622 additions and 198 deletions
@@ -1,12 +1,14 @@
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/anonpay_transaction_row.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/order_row.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/payjoin_transaction_row.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/trade_row.dart';
import 'package:cake_wallet/themes/extensions/placeholder_theme.dart';
import 'package:cake_wallet/src/widgets/dashboard_card_widget.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:cake_wallet/view_model/dashboard/order_list_item.dart';
import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/sync_status.dart';
@@ -143,6 +145,25 @@ class TransactionsPage extends StatelessWidget {
);
}
if (item is PayjoinTransactionListItem) {
final session = item.session;
return PayjoinTransactionRow(
key: item.key,
onTap: () => Navigator.of(context).pushNamed(
Routes.payjoinDetails,
arguments: [item.sessionId, item.transaction],
),
currency: "BTC",
state: item.status,
amount: bitcoin!.formatterBitcoinAmountToString(
amount: session.amount.toInt()),
createdAt: DateFormat('HH:mm')
.format(session.inProgressSince!),
isSending: session.isSenderSession,
);
}
if (item is TradeListItem) {
final trade = item.trade;
@@ -0,0 +1,99 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart';
import 'package:flutter/material.dart';
class PayjoinTransactionRow extends StatelessWidget {
PayjoinTransactionRow({
required this.createdAt,
required this.currency,
required this.onTap,
required this.amount,
required this.state,
required this.isSending,
super.key,
});
final VoidCallback? onTap;
final String createdAt;
final String amount;
final String currency;
final String state;
final bool isSending;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: EdgeInsets.fromLTRB(24, 8, 24, 8),
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_getImage(),
SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
"${isSending ? S.current.outgoing : S.current.incoming} Payjoin",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.extension<DashboardPageTheme>()!
.textColor,
),
),
Text(
amount + ' ' + currency,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.extension<DashboardPageTheme>()!
.textColor,
),
)
]),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
createdAt,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.extension<CakeTextTheme>()!
.dateSectionRowColor,
),
),
Text(
state,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.extension<CakeTextTheme>()!
.dateSectionRowColor,
),
),
])
],
))
],
),
));
}
Widget _getImage() => ClipRRect(
borderRadius: BorderRadius.circular(50),
child: Image.asset('assets/images/payjoin.png', width: 36, height: 36));
}
@@ -0,0 +1,77 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/trade_details/trade_details_list_card.dart';
import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
import 'package:cake_wallet/src/widgets/list_row.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/src/widgets/standard_list_card.dart';
import 'package:cake_wallet/src/widgets/standard_list_status_row.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:cake_wallet/view_model/payjoin_details_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class PayjoinDetailsPage extends BasePage {
PayjoinDetailsPage({required this.payjoinDetailsViewModel});
@override
String get title => S.current.payjoin_details;
final PayjoinDetailsViewModel payjoinDetailsViewModel;
@override
Widget body(BuildContext context) => PayjoinDetailsPageBody(payjoinDetailsViewModel);
}
class PayjoinDetailsPageBody extends StatefulWidget {
PayjoinDetailsPageBody(this.payjoinDetailsViewModel);
final PayjoinDetailsViewModel payjoinDetailsViewModel;
@override
State<PayjoinDetailsPageBody> createState() => _PayjoinDetailsPageBodyState();
}
class _PayjoinDetailsPageBodyState extends State<PayjoinDetailsPageBody> {
@override
void dispose() {
super.dispose();
widget.payjoinDetailsViewModel.listener.cancel();
}
@override
Widget build(BuildContext context) {
return SectionStandardList(
sectionCount: 1,
itemCounter: (int _) => widget.payjoinDetailsViewModel.items.length,
itemBuilder: (__, index) {
final item = widget.payjoinDetailsViewModel.items[index];
if (item is DetailsListStatusItem) {
return StandardListStatusRow(
title: item.title,
value: item.value,
status: item.status,
);
}
if (item is TradeDetailsListCardItem) {
return TradeDetailsStandardListCard(
id: item.id,
create: item.createdAt,
pair: item.pair,
currentTheme: widget.payjoinDetailsViewModel.settingsStore.currentTheme.type,
onTap: item.onTap,
);
}
return GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: item.value));
showBar<void>(context, S.of(context).transaction_details_copied(item.title));
},
child: ListRow(title: '${item.title}:', value: item.value),
);
});
}
}
+67 -19
View File
@@ -1,4 +1,6 @@
import 'package:cake_wallet/entities/qr_view_data.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/picker_theme.dart';
import 'package:cake_wallet/themes/extensions/qr_code_theme.dart';
import 'package:cake_wallet/routes.dart';
@@ -91,26 +93,54 @@ class QRWidget extends StatelessWidget {
child: Hero(
tag: Key(heroTag ?? addressUri.toString()),
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
border: Border.all(
width: 3,
color: Theme.of(context)
.extension<DashboardPageTheme>()!
.textColor,
),
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
width: 3,
color: Colors.white,
child: Container(
padding: EdgeInsets.zero,
decoration: BoxDecoration(
border: Border(top: BorderSide.none),
borderRadius:
BorderRadius.all(Radius.circular(5)),
color: Colors.white,
),
child: Column(
children: [
Container(
padding: EdgeInsets.all(3),
child: AspectRatio(
aspectRatio: 1.0,
child: QrImage(
data: addressUri.toString(),
),
),
child: QrImage(data: addressUri.toString())),
),
if (addressListViewModel
.payjoinEndpoint.isNotEmpty) ...[
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(
top: 4,
bottom: 4,
right: 4,
),
child: Image.asset(
'assets/images/payjoin.png',
width: 20,
),
),
Text(
S.of(context).payjoin_enabled,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
]
],
),
),
),
@@ -179,7 +209,25 @@ class QRWidget extends StatelessWidget {
),
),
),
)
),
if (addressListViewModel.payjoinEndpoint.isNotEmpty) ...[
Padding(
padding: EdgeInsets.only(top: 12),
child: PrimaryImageButton(
onPressed: () {
Clipboard.setData(ClipboardData(
text: addressListViewModel.payjoinEndpoint));
showBar<void>(context, S.of(context).copied_to_clipboard);
},
image: Image.asset('assets/images/payjoin.png', width: 25,),
text: S.of(context).copy_payjoin_url,
color: Theme.of(context).cardColor,
textColor: Theme.of(context)
.extension<CakeTextTheme>()!
.buttonTextColor,
),
),
],
],
),
),
+9 -1
View File
@@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/core/totp_request_details.dart';
import 'package:cake_wallet/utils/device_info.dart';
@@ -136,6 +136,10 @@ class RootState extends State<Root> with WidgetsBindingObserver {
setState(() => _setInactive(true));
}
if (widget.appStore.wallet?.type == WalletType.bitcoin) {
bitcoin!.stopPayjoinSessions(widget.appStore.wallet!);
}
break;
case AppLifecycleState.resumed:
widget.authService.requireAuth().then((value) {
@@ -145,6 +149,10 @@ class RootState extends State<Root> with WidgetsBindingObserver {
});
}
});
if (widget.appStore.wallet?.type == WalletType.bitcoin &&
widget.appStore.settingsStore.usePayjoin) {
bitcoin!.resumePayjoinSessions(widget.appStore.wallet!);
}
break;
default:
break;
+1 -1
View File
@@ -463,7 +463,7 @@ class SendPage extends BasePage {
},
);
},
text: S.of(context).send,
text: sendViewModel.payjoinUri != null ? S.of(context).send_payjoin : S.of(context).send,
color: Theme.of(context).primaryColor,
textColor: Colors.white,
isLoading: sendViewModel.state is IsExecutingState ||
+17 -13
View File
@@ -188,19 +188,23 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
sendViewModel.createOpenCryptoPayTransaction(uri.toString());
} else {
final paymentRequest = PaymentRequest.fromUri(uri);
addressController.text = paymentRequest.address;
cryptoAmountController.text = paymentRequest.amount;
noteController.text = paymentRequest.note;
}
},
options: [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
AddressTextFieldOption.addressBook
],
buttonColor: Theme.of(context).extension<SendPageTheme>()!.textFieldButtonColor,
borderColor: Theme.of(context).extension<SendPageTheme>()!.textFieldBorderColor,
textStyle:
if (sendViewModel.usePayjoin) {
sendViewModel.payjoinUri = paymentRequest.pjUri;
}
addressController.text = paymentRequest.address;
cryptoAmountController.text = paymentRequest.amount;
noteController.text = paymentRequest.note;}
},
options: [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
AddressTextFieldOption.addressBook
],
buttonColor:
Theme.of(context).extension<SendPageTheme>()!.textFieldButtonColor,
borderColor:
Theme.of(context).extension<SendPageTheme>()!.textFieldBorderColor,
textStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white),
hintStyle: TextStyle(
fontSize: 14,
@@ -49,6 +49,14 @@ class PrivacyPage extends BasePage {
_privacySettingsViewModel.setExchangeApiMode(mode),
),
),
if (_privacySettingsViewModel.canUsePayjoin)
SettingsSwitcherCell(
title: S.of(context).use_payjoin,
value: _privacySettingsViewModel.usePayjoin,
onValueChange: (BuildContext _, bool value) {
_privacySettingsViewModel.setUsePayjoin(value);
},
),
SettingsSwitcherCell(
title: S.current.settings_save_recipient_address,
value: _privacySettingsViewModel.shouldSaveRecipientAddress,
@@ -2,6 +2,8 @@ import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.d
class DetailsListStatusItem extends StandartListItem {
DetailsListStatusItem(
{required String title, required String value})
{required String title, required String value, this.status})
: super(title: title, value: value);
final String? status; // waiting, action required, created, fetching, finished, success
}
+19 -15
View File
@@ -1,21 +1,20 @@
import 'package:cake_wallet/utils/device_info.dart';
import 'package:cake_wallet/entities/contact_base.dart';
import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/send_page_theme.dart';
import 'package:cake_wallet/utils/device_info.dart';
import 'package:cake_wallet/utils/permission_handler.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/entities/contact_base.dart';
import 'package:cake_wallet/themes/extensions/send_page_theme.dart';
import 'package:cake_wallet/utils/permission_handler.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
enum AddressTextFieldOption { paste, qrCode, addressBook, walletAddresses }
class AddressTextField<T extends Currency> extends StatelessWidget{
class AddressTextField<T extends Currency> extends StatelessWidget {
AddressTextField({
required this.controller,
this.isActive = true,
@@ -234,9 +233,7 @@ class AddressTextField<T extends Currency> extends StatelessWidget{
if (!isCameraPermissionGranted) return;
final code = await presentQRScanner(context);
if (code == null) return;
if (code.isEmpty) {
return;
}
if (code.isEmpty) return;
try {
final uri = Uri.parse(code);
@@ -259,7 +256,8 @@ class AddressTextField<T extends Currency> extends StatelessWidget{
}
Future<void> _presetWalletAddressPicker(BuildContext context) async {
final address = await Navigator.of(context).pushNamed(Routes.pickerWalletAddress);
final address =
await Navigator.of(context).pushNamed(Routes.pickerWalletAddress);
if (address is String) {
controller?.text = address;
@@ -272,7 +270,13 @@ class AddressTextField<T extends Currency> extends StatelessWidget{
final address = clipboard?.text ?? '';
if (address.isNotEmpty) {
controller?.text = address;
try {
final uri = Uri.parse(address);
controller?.text = uri.path;
onURIScanned?.call(uri);
} catch (_) {
controller?.text = address;
}
}
onPushPasteButton?.call(context);
@@ -1,5 +1,4 @@
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/palette.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -7,10 +6,11 @@ import 'package:cake_wallet/themes/extensions/address_theme.dart';
import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart';
class StandardListStatusRow extends StatelessWidget {
StandardListStatusRow({required this.title, required this.value});
StandardListStatusRow({required this.title, required this.value, this.status});
final String title;
final String value;
final String? status; // waiting, action required, created, fetching, finished, success
@override
Widget build(BuildContext context) {
@@ -43,7 +43,7 @@ class StandardListStatusRow extends StatelessWidget {
children: <Widget>[
SyncIndicatorIcon(
boolMode: false,
value: value,
value: status ?? value,
size: 6,
),
SizedBox(