feat(ur): support BBQR for coldcard qr

This commit is contained in:
Czarek Nakamoto 2025-06-17 19:42:43 +02:00 committed by cyan
parent eeaa8b434d
commit 43f4dfa6f0
16 changed files with 110 additions and 35 deletions

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:bbqrdart/bbqrdart.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:grpc/grpc.dart';
import 'package:cw_bitcoin/exceptions.dart';
@ -183,7 +184,7 @@ class PendingBitcoinTransaction with PendingTransaction {
bool shouldCommitUR() => isViewOnly;
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
var sourceBytes = unsignedPsbt!;
var cborEncoder = CBOREncoder();
cborEncoder.encodeBytes(sourceBytes);
@ -194,6 +195,19 @@ class PendingBitcoinTransaction with PendingTransaction {
while (!encoded.isComplete) {
values.add(encoded.nextPart());
}
return Future.value(values.join("\n"));
final bbqrObj = BBQRPsbt.fromUint8List(sourceBytes);
List<String> bbqr = [
bbqrObj.asString(),
];
while (!bbqrObj.isDone) {
bbqrObj.next();
bbqr.add(bbqrObj.asString());
}
return Future.value({
"PSBT (bcur)": values.join("\n"),
"PSBT (bbqr)": bbqr.join("\n"),
});
}
}

View file

@ -46,6 +46,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.11.0"
bbqrdart:
dependency: "direct main"
description:
path: "."
ref: b0f3f72911221bafed0ab77359fccd64bc79d524
resolved-ref: b0f3f72911221bafed0ab77359fccd64bc79d524
url: "https://github.com/mrcyjanek/bbqrdart"
source: git
version: "1.0.0"
bech32:
dependency: "direct main"
description:
@ -1191,5 +1200,5 @@ packages:
source: hosted
version: "2.2.2"
sdks:
dart: ">=3.6.0 <4.0.0"
dart: ">=3.6.2 <4.0.0"
flutter: ">=3.27.0"

View file

@ -62,6 +62,10 @@ dependencies:
git:
url: https://github.com/bukata-sa/bc-ur-dart
ref: 5738f70d0ec3d50977ac3dd01fed62939600238b
bbqrdart:
git:
url: https://github.com/mrcyjanek/bbqrdart
ref: b0f3f72911221bafed0ab77359fccd64bc79d524
dev_dependencies:
flutter_test:

View file

@ -86,7 +86,7 @@ class PendingBitcoinCashTransaction with PendingTransaction {
isReplaced: false,
);
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -17,5 +17,5 @@ mixin PendingTransaction {
bool shouldCommitUR() => false;
Future<void> commit();
Future<String?> commitUR();
Future<Map<String, String>> commitUR();
}

View file

@ -33,7 +33,7 @@ class DecredPendingTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -52,7 +52,7 @@ class PendingEVMChainTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -65,7 +65,7 @@ class PendingMoneroTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() async {
Future<Map<String, String>> commitUR() async {
try {
final ret = await monero_transaction_history.commitTransactionFromPointerAddress(
address: pendingTransactionDescription.pointerAddress,
@ -75,7 +75,10 @@ class PendingMoneroTransaction with PendingTransaction {
await Future.delayed(const Duration(milliseconds: 250));
await wallet.fetchTransactions();
}());
return ret;
if (ret == null) return {};
return {
"xmr-txsigned": ret,
};
} catch (e) {
final message = e.toString();

View file

@ -39,7 +39,7 @@ class PendingNanoTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -41,7 +41,7 @@ class PendingSolanaTransaction with PendingTransaction {
String get id => '';
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -32,7 +32,7 @@ class PendingTronTransaction with PendingTransaction {
String get id => '';
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -52,7 +52,7 @@ class PendingWowneroTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -46,7 +46,7 @@ class PendingZanoTransaction with PendingTransaction {
}
@override
Future<String?> commitUR() {
Future<Map<String, String>> commitUR() {
throw UnimplementedError();
}
}

View file

@ -971,7 +971,7 @@ Future<void> setup({
getIt.registerFactory(() => AnimatedURModel(getIt.get<AppStore>()));
getIt.registerFactoryParam<AnimatedURPage, String, void>((String urQr, _) =>
getIt.registerFactoryParam<AnimatedURPage, Map<String, String>, void>((Map<String, String> urQr, _) =>
AnimatedURPage(getIt.get<AnimatedURModel>(), urQr: urQr));
getIt.registerFactoryParam<ContactViewModel, ContactRecord?, void>(

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:bbqrdart/bbqrdart.dart';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/generated/i18n.dart';
@ -20,22 +21,21 @@ import 'package:flutter/services.dart';
class AnimatedURPage extends BasePage {
final bool isAll;
AnimatedURPage(this.animatedURmodel, {required String urQr, this.isAll = false}) {
if (urQr == "export-outputs") {
this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, false);
} else if (urQr == "export-outputs-all") {
this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, true);
} else {
this.urQr = urQr;
}
}
AnimatedURPage(this.animatedURmodel, {
required this.urQr,
this.isAll = false,
});
late String urQr;
late Map<String, String> urQr;
final AnimatedURModel animatedURmodel;
String get urQrType {
final first = urQr.trim().split("\n")[0];
if (urQr.values.first.trim().substring(0, 2) == BBQR.header) {
return BBQR.header;
}
if (urQr.isEmpty) return "unknown";
final first = urQr.values.first.trim().split("\n")[0];
return first.split('/')[0];
}
@ -47,10 +47,10 @@ class AnimatedURPage extends BasePage {
Padding(
padding: const EdgeInsets.only(top: 64.0),
child: URQR(
frames: urQr.trim().split("\n"),
urqr: urQr,
),
),
if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt"].contains(urQrType)) ...{
if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt", BBQR.header].contains(urQrType)) ...{
SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@ -65,6 +65,7 @@ class AnimatedURPage extends BasePage {
),
),
},
if (urQrType == "ur:xmr-output" && !isAll) ...{
SizedBox(height: 32),
Padding(
@ -88,7 +89,13 @@ class AnimatedURPage extends BasePage {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) {
return AnimatedURPage(animatedURmodel, urQr: "export-outputs-all", isAll: true);
return AnimatedURPage(
animatedURmodel,
urQr: {
"export-outputs-all": "export-outputs-all",
},
isAll: true,
);
},
),
);
@ -136,9 +143,9 @@ class AnimatedURPage extends BasePage {
}
class URQR extends StatefulWidget {
URQR({super.key, required this.frames});
URQR({super.key, required this.urqr});
List<String> frames;
final Map<String, String> urqr;
@override
// ignore: library_private_types_in_public_api
@ -172,6 +179,24 @@ class _URQRState extends State<URQR> {
super.dispose();
}
late String selected = (widget.urqr.isEmpty) ? "unknown" : widget.urqr.keys.first;
int selectedInt = 0;
List<String> get frames {
return widget.urqr[selected]?.split("\n") ?? [];
}
late String nextLabel = widget.urqr.keys.toList()[(selectedInt + 1) % widget.urqr.length] ;
void next() {
final keys = widget.urqr.keys.toList();
selectedInt++;
setState(() {
nextLabel = keys[(selectedInt + 1) % keys.length];
selected = keys[(selectedInt + 1) % keys.length];
});
}
@override
Widget build(BuildContext context) {
return Column(
@ -180,19 +205,35 @@ class _URQRState extends State<URQR> {
children: [
Center(
child: QrImage(
data: widget.frames[frame % widget.frames.length],
data: frames[frame % frames.length],
version: -1,
size: 400,
),
),
if (widget.urqr.values.length > 1)
SizedBox(
width: double.maxFinite,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: next,
text: nextLabel,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
if (FeatureFlag.hasDevOptions) ...{
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: """Current frame (${frame % widget.frames.length}): ${widget.frames[frame % widget.frames.length]},
Clipboard.setData(ClipboardData(text: """Current frame (${frame % frames.length}): ${frames[frame % frames.length]},
All frames:
- ${widget.frames.join("\n - ")}"""));
- ${frames.join("\n - ")}"""));
},
child: Text(widget.frames[frame % widget.frames.length]),
child: Text(frames[frame % frames.length]),
),
}
],

View file

@ -134,6 +134,10 @@ dependencies:
git:
url: https://github.com/bukata-sa/bc-ur-dart
ref: 5738f70d0ec3d50977ac3dd01fed62939600238b
bbqrdart:
git:
url: https://github.com/mrcyjanek/bbqrdart
ref: b0f3f72911221bafed0ab77359fccd64bc79d524
dev_dependencies:
flutter_test: