From 0471b6ded5e999e29f8f75836317072840e0761c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 17:37:14 -0500 Subject: [PATCH 01/19] fix(paynym): handle empty/non-JSON response bodies in _post() wrap jsonDecode(response.body) in a try/catch with isEmpty guard to prevent FormatException when paynym.rs returns non-200 with empty body --- lib/utilities/paynym_is_api.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/utilities/paynym_is_api.dart b/lib/utilities/paynym_is_api.dart index 9285fef7f..5230f1734 100644 --- a/lib/utilities/paynym_is_api.dart +++ b/lib/utilities/paynym_is_api.dart @@ -65,10 +65,16 @@ class PaynymIsApi { // debugPrint("Paynym response code: ${response.code}"); // debugPrint("Paynym response body: ${response.body}"); - return Tuple2( - jsonDecode(response.body) as Map, - response.code, - ); + Map parsedBody; + try { + final bodyStr = response.body.trim(); + parsedBody = bodyStr.isEmpty + ? {} + : jsonDecode(bodyStr) as Map; + } catch (_) { + parsedBody = {}; + } + return Tuple2(parsedBody, response.code); } // ### `/api/v1/create` From 92084c0e0ad74bd93d13deda10764b582e010e37 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 17:42:25 -0500 Subject: [PATCH 02/19] test(paynym): add PayNym.rs POST tests for endpoints TODO: refactor `paynym_is`->`paynym_rs` chore: dart format --- test/services/paynym/paynym_is_api_test.dart | 245 ++++++++++++++++++ .../paynym/paynym_is_api_test.mocks.dart | 99 +++++++ 2 files changed, 344 insertions(+) create mode 100644 test/services/paynym/paynym_is_api_test.dart create mode 100644 test/services/paynym/paynym_is_api_test.mocks.dart diff --git a/test/services/paynym/paynym_is_api_test.dart b/test/services/paynym/paynym_is_api_test.dart new file mode 100644 index 000000000..fa2e651a2 --- /dev/null +++ b/test/services/paynym/paynym_is_api_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/utilities/paynym_is_api.dart'; + +import 'paynym_is_api_test.mocks.dart'; + +@GenerateMocks([HTTP]) +void main() { + late PaynymIsApi api; + late MockHTTP client; + + setUp(() { + client = MockHTTP(); + api = PaynymIsApi(); + api.client = client; + }); + + void stubPost( + String endpoint, + String responseBody, + int statusCode, { + Map? extraHeaders, + }) { + when( + client.post( + url: Uri.parse('https://paynym.rs/api/v1$endpoint'), + headers: anyNamed('headers'), + proxyInfo: anyNamed('proxyInfo'), + body: anyNamed('body'), + encoding: anyNamed('encoding'), + ), + ).thenAnswer((_) async => Response(utf8.encode(responseBody), statusCode)); + } + + group('create', () { + test('400 with empty body returns typed error', () async { + stubPost('/create', '', 400); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('201 with valid JSON returns CreatedPaynym', () async { + stubPost( + '/create', + '{"claimed":false,"nymID":"abc","nymName":"foo","segwit":true,"token":"tok"}', + 201, + ); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 201); + expect(r.message, 'PayNym created successfully'); + expect(r.value, isNotNull); + expect(r.value!.nymId, 'abc'); + }); + + test('200 returns existing PayNym', () async { + stubPost( + '/create', + '{"claimed":true,"nymID":"abc","nymName":"foo","segwit":true,"token":"tok"}', + 200, + ); + final r = await api.create('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'PayNym already exists'); + expect(r.value, isNotNull); + }); + }); + + group('token', () { + test('404 with empty body returns typed error', () async { + stubPost('/token', '', 404); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code was not found'); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/token', '', 400); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns token string', () async { + stubPost('/token', '{"token":"testToken123"}', 200); + final r = await api.token('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'Token was successfully updated'); + expect(r.value, 'testToken123'); + }); + }); + + group('nym', () { + test('404 with empty body returns typed error', () async { + stubPost('/nym', '', 404); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 404); + expect(r.message, 'Nym not found'); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/nym', '', 400); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns PaynymAccount', () async { + stubPost( + '/nym', + jsonEncode({ + 'nymID': 'testId', + 'nymName': 'testName', + 'segwit': true, + 'codes': [ + {'claimed': true, 'segwit': true, 'code': 'PM8Ttest'}, + ], + 'followers': >[], + 'following': >[], + }), + 200, + ); + final r = await api.nym('PM8Ttest'); + expect(r.statusCode, 200); + expect(r.message, 'Nym found and returned'); + expect(r.value, isNotNull); + expect(r.value!.nymID, 'testId'); + }); + }); + + group('claim', () { + test('400 with empty body returns typed error', () async { + stubPost('/claim', '', 400); + final r = await api.claim('tok', 'sig'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + + test('200 with valid JSON returns PaynymClaim', () async { + stubPost('/claim', '{"claimed":"PM8Ttest","token":"newTok"}', 200); + final r = await api.claim('tok', 'sig'); + expect(r.statusCode, 200); + expect(r.message, 'Payment code successfully claimed'); + expect(r.value, isNotNull); + expect(r.value!.claimed, 'PM8Ttest'); + }); + }); + + group('follow', () { + test('404 with empty body returns typed error', () async { + stubPost('/follow', '', 404); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code not found'); + expect(r.value, isNull); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/follow', '', 401); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/follow', '', 400); + final r = await api.follow('tok', 'sig', 'target'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + }); + + group('unfollow', () { + test('404 with empty body returns typed error', () async { + stubPost('/unfollow', '', 404); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 404); + expect(r.message, 'Payment code not found'); + expect(r.value, isNull); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/unfollow', '', 401); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, isNull); + }); + + test('400 with empty body returns typed error', () async { + stubPost('/unfollow', '', 400); + final r = await api.unfollow('tok', 'sig', 'target'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, isNull); + }); + }); + + group('add', () { + test('400 with empty body returns typed error', () async { + stubPost('/nym/add', '', 400); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 400); + expect(r.message, 'Bad request'); + expect(r.value, false); + }); + + test('401 with empty body returns typed error', () async { + stubPost('/nym/add', '', 401); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 401); + expect( + r.message, + 'Unauthorized token or signature or Unclaimed payment code', + ); + expect(r.value, false); + }); + + test('404 with empty body returns typed error', () async { + stubPost('/nym/add', '', 404); + final r = await api.add('tok', 'sig', 'nym', 'code'); + expect(r.statusCode, 404); + expect(r.message, 'Nym not found'); + expect(r.value, false); + }); + }); +} diff --git a/test/services/paynym/paynym_is_api_test.mocks.dart b/test/services/paynym/paynym_is_api_test.mocks.dart new file mode 100644 index 000000000..e3d6837fa --- /dev/null +++ b/test/services/paynym/paynym_is_api_test.mocks.dart @@ -0,0 +1,99 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in stackwallet/test/services/paynym/paynym_is_api_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i5; +import 'dart:io' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:stackwallet/networking/http.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [HTTP]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHTTP extends _i1.Mock implements _i2.HTTP { + MockHTTP() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> get({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) => + (super.noSuchMethod( + Invocation.method(#get, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + #connectionTimeout: connectionTimeout, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post({ + required Uri? url, + Map? headers, + Object? body, + _i5.Encoding? encoding, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#post, [], { + #url: url, + #headers: headers, + #body: body, + #encoding: encoding, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#post, [], { + #url: url, + #headers: headers, + #body: body, + #encoding: encoding, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); +} From 8bcc165999ab50cd5ce84460f606643bd7c82422 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:38:52 -0500 Subject: [PATCH 03/19] fix(paynym): use Bitcoin message signature for PayNym claim --- .../paynym_interface.dart | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 0d993036d..bb6958418 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -342,10 +342,26 @@ mixin PaynymInterface } Future signStringWithNotificationKey(String data) async { - final bytes = await signWithNotificationKey( - Uint8List.fromList(utf8.encode(data)), + final myPrivateKeyNode = await deriveNotificationBip32Node(); + final key = coinlib.ECPrivateKey(myPrivateKeyNode.privateKey!); + + // Clean prefix: strip leading length byte if present (coinlib recalculates) + final prefixBytes = + cryptoCurrency.networkParams.messagePrefix.toUint8ListFromUtf8; + final ignoreFirstByte = + prefixBytes.first == prefixBytes.length - 1; + final prefix = (ignoreFirstByte + ? prefixBytes.sublist(1) + : prefixBytes) + .toUtf8String; + + final signed = coinlib.MessageSignature.sign( + key: key, + message: data, + prefix: prefix, ); - return Format.uint8listToString(bytes); + + return base64Encode(signed.signature.compact); } Future preparePaymentCodeSend({ From ec0542ffc6b688ff95f27adf40e77059cf5bb141 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:38:58 -0500 Subject: [PATCH 04/19] fix(paynym): handle bool "claimed" field in PaynymClaim.fromMap --- lib/models/paynym/paynym_claim.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/paynym/paynym_claim.dart b/lib/models/paynym/paynym_claim.dart index 0f1e66373..36afef7bd 100644 --- a/lib/models/paynym/paynym_claim.dart +++ b/lib/models/paynym/paynym_claim.dart @@ -15,7 +15,7 @@ class PaynymClaim { PaynymClaim(this.claimed, this.token); PaynymClaim.fromMap(Map map) - : claimed = map["claimed"] as String, + : claimed = map["claimed"].toString(), token = map["token"] as String; Map toMap() => { From 208177302da5c4aab01ec6bb363b908030555a4d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:39:04 -0500 Subject: [PATCH 05/19] fix(paynym): handle claim success check and add null-token guard --- lib/pages/paynym/paynym_claim_view.dart | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index 8d61e139c..4eb3c9b93 100644 --- a/lib/pages/paynym/paynym_claim_view.dart +++ b/lib/pages/paynym/paynym_claim_view.dart @@ -240,12 +240,24 @@ class _PaynymClaimViewState extends ConsumerState { final token = await ref.read(paynymAPIProvider).token(pCode.toString()); + debugPrint("token result: $token"); + if (shouldCancel) return; + if (token.value == null) { + debugPrint("token fetch failed: ${token.message}"); + if (mounted) { + Navigator.of(context, rootNavigator: isDesktop).pop(); + } + return; + } + // sign token with notification private key final signature = await wallet.signStringWithNotificationKey(token.value!); + debugPrint("signature: $signature"); + if (shouldCancel) return; // claim paynym account @@ -253,9 +265,13 @@ class _PaynymClaimViewState extends ConsumerState { .read(paynymAPIProvider) .claim(token.value!, signature); + debugPrint("claim result: $claim"); + if (shouldCancel) return; - if (claim.value?.claimed == pCode.toString()) { + if (claim.value != null && + (claim.value!.claimed == pCode.toString() || + claim.value!.claimed == "true")) { final account = await ref.read(paynymAPIProvider).nym(pCode.toString()); // if (!account.value!.segwit) { @@ -286,6 +302,13 @@ class _PaynymClaimViewState extends ConsumerState { ); } } else if (mounted && !shouldCancel) { + debugPrint( + "claim failed or mismatch: " + "claimed=${claim.value?.claimed}, " + "expected=${pCode.toString()}, " + "statusCode=${claim.statusCode}, " + "message=${claim.message}", + ); Navigator.of(context, rootNavigator: isDesktop).pop(); } }, From 290eae2da69acbda8a79c58fdf67c2f53e0627d8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:39:15 -0500 Subject: [PATCH 06/19] fix(linux): enable secp256k1 recovery module in build script --- scripts/linux/build_secp256k1.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/linux/build_secp256k1.sh b/scripts/linux/build_secp256k1.sh index e139cc937..b6037a306 100755 --- a/scripts/linux/build_secp256k1.sh +++ b/scripts/linux/build_secp256k1.sh @@ -6,8 +6,9 @@ fi cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard +rm -rf build mkdir -p build && cd build -cmake .. +cmake .. -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cmake --build . mkdir -p ../../../../../build cp lib/libsecp256k1.so.2.*.* "../../../../../build/libsecp256k1.so" From 0b9bbf155bbc897aa95e35d1c6f34c1afac9e76e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:39:24 -0500 Subject: [PATCH 07/19] fix(windows): enable secp256k1 recovery module in WSL build script --- scripts/windows/build_secp256k1_wsl.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh index a39cd3bee..cedb2bc2c 100644 --- a/scripts/windows/build_secp256k1_wsl.sh +++ b/scripts/windows/build_secp256k1_wsl.sh @@ -6,8 +6,9 @@ fi cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard +rm -rf build mkdir -p build && cd build -cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake +cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cmake --build . mkdir -p ../../../../../build cp bin/libsecp256k1-2.dll "../../../../../build/secp256k1.dll" From ab43945bccb5be0aebe05eb00cf25307390416c7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 23 Mar 2026 20:39:33 -0500 Subject: [PATCH 08/19] fix(windows): enable secp256k1 recovery module in batch build script --- scripts/windows/build_secp256k1.bat | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/windows/build_secp256k1.bat b/scripts/windows/build_secp256k1.bat index bae7c9788..b619e6e78 100644 --- a/scripts/windows/build_secp256k1.bat +++ b/scripts/windows/build_secp256k1.bat @@ -4,7 +4,8 @@ git clone https://github.com/bitcoin-core/secp256k1 cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard -cmake -G "Visual Studio 17 2022" -A x64 -S . -B build +if exist "build" rmdir /s /q "build" +cmake -G "Visual Studio 17 2022" -A x64 -S . -B build -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cd build cmake --build . if not exist "..\..\..\..\..\build\" mkdir "..\..\..\..\..\build\" From e28f7695f2072e92479b6d3aba10a3d9100bba4a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 20:04:33 -0500 Subject: [PATCH 09/19] fix(paynym): handle 401 and empty body in claim() response --- lib/utilities/paynym_is_api.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/utilities/paynym_is_api.dart b/lib/utilities/paynym_is_api.dart index 5230f1734..9aafb7136 100644 --- a/lib/utilities/paynym_is_api.dart +++ b/lib/utilities/paynym_is_api.dart @@ -363,11 +363,16 @@ class PaynymIsApi { switch (result.item2) { case 200: message = "Payment code successfully claimed"; - value = PaynymClaim.fromMap(result.item1); + if (result.item1.isNotEmpty) { + value = PaynymClaim.fromMap(result.item1); + } break; case 400: message = "Bad request"; break; + case 401: + message = "Unauthorized token or signature"; + break; default: message = result.item1["message"] as String? ?? "Unknown error"; } From 89cc01f5c9e19cc549a3d1c8a6ba757006ad0d11 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 20:04:56 -0500 Subject: [PATCH 10/19] fix(paynym): navigate to PaynymHomeView when nym already claimed --- lib/pages/paynym/paynym_claim_view.dart | 42 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index 4eb3c9b93..cb04fda4f 100644 --- a/lib/pages/paynym/paynym_claim_view.dart +++ b/lib/pages/paynym/paynym_claim_view.dart @@ -206,21 +206,33 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; if (created.value!.claimed) { - // payment code already claimed + // payment code already claimed — load account and navigate debugPrint("pcode already claimed!!"); - // final account = - // await ref.read(paynymAPIProvider).nym(pCode.toString()); - // if (!account.value!.segwit) { - // for (int i = 0; i < 100; i++) { - // final result = await _addSegwitCode(account.value!); - // if (result == true) { - // break; - // } - // } - // } + final account = await ref + .read(paynymAPIProvider) + .nym(pCode.toString()); - if (mounted) { + if (shouldCancel) return; + + if (account.value != null && mounted) { + ref.read(myPaynymAccountStateProvider.state).state = + account.value!; + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pop(); + } else { + Navigator.of(context).popUntil( + ModalRoute.withName( + WalletView.routeName, + ), + ); + } + await Navigator.of(context).pushNamed( + PaynymHomeView.routeName, + arguments: widget.walletId, + ); + } else if (mounted) { if (isDesktop) { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); @@ -269,9 +281,9 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; - if (claim.value != null && - (claim.value!.claimed == pCode.toString() || - claim.value!.claimed == "true")) { + if (claim.statusCode == 200 || + claim.value?.claimed == pCode.toString() || + claim.value?.claimed == "true") { final account = await ref.read(paynymAPIProvider).nym(pCode.toString()); // if (!account.value!.segwit) { From e2754ad5afefa7cc6b68b2180bdff7b50f477633 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 20:05:30 -0500 Subject: [PATCH 11/19] fix(paynym): re-enable following and fix null-unsafe success checks --- .../paynym_follow_toggle_button.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart index a2c4db100..b512bbb88 100644 --- a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart +++ b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart @@ -35,7 +35,7 @@ enum PaynymFollowToggleButtonStyle { detailsDesktop, } -const kDisableFollowing = true; +const kDisableFollowing = false; class PaynymFollowToggleButton extends ConsumerStatefulWidget { const PaynymFollowToggleButton({ @@ -115,7 +115,10 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Follow result: $result on try $i"); - if (result.value!.following == followedAccount.value!.nymID) { + final followSuccess = result.statusCode == 200 || + result.value?.following == followedAccount.value?.nymID; + + if (followSuccess && followedAccount.value != null) { if (!loadingPopped && mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } @@ -157,7 +160,7 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to follow ${followedAccount.value!.nymName}", + message: "Failed to follow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); @@ -222,7 +225,10 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Unfollow result: $result on try $i"); - if (result.value!.unfollowing == followedAccount.value!.nymID) { + final unfollowSuccess = result.statusCode == 200 || + result.value?.unfollowing == followedAccount.value?.nymID; + + if (unfollowSuccess && followedAccount.value != null) { if (!loadingPopped && mounted) { Navigator.of(context, rootNavigator: isDesktop).pop(); } @@ -258,7 +264,7 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to unfollow ${followedAccount.value!.nymName}", + message: "Failed to unfollow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); From 78992295e7271961a1e27f67a458b9fec80381b7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 20:06:11 -0500 Subject: [PATCH 12/19] fix(paynym): handle taproot inputs in notification tx parsing/building --- .../paynym_interface.dart | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index bb6958418..c184033df 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -512,8 +512,19 @@ mixin PaynymInterface ); } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + // Sort spendable by age (oldest first), but push taproot UTXOs to the + // end since taproot inputs don't expose the raw public key needed by the + // receiver to compute ECDH for BIP47 notification parsing. + spendableOutputs.sort((a, b) { + final aIsTaproot = a.address?.startsWith('bc1p') == true || + a.address?.startsWith('tb1p') == true; + final bIsTaproot = b.address?.startsWith('bc1p') == true || + b.address?.startsWith('tb1p') == true; + if (aIsTaproot != bIsTaproot) { + return aIsTaproot ? 1 : -1; + } + return b.blockTime!.compareTo(a.blockTime!); + }); BigInt satoshisBeingUsed = BigInt.zero; int outputsBeingUsed = 0; @@ -1122,7 +1133,12 @@ mixin PaynymInterface final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); - final pubKey = _pubKeyFromInput(designatedInput)!; + final pubKey = _pubKeyFromInput(designatedInput); + + // Taproot inputs don't expose the raw public key — can't compute ECDH. + if (pubKey == null) { + return null; + } final myPrivateKey = (await deriveNotificationBip32Node()).privateKey!; @@ -1181,7 +1197,12 @@ mixin PaynymInterface final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); - final pubKey = _pubKeyFromInput(designatedInput)!; + final pubKey = _pubKeyFromInput(designatedInput); + + // Taproot inputs don't expose the raw public key — can't compute ECDH. + if (pubKey == null) { + return null; + } final myPrivateKey = (await deriveNotificationBip32Node()).privateKey!; From e5cefa9a7956b6d0df51f424bccb3958d0113c77 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 20:59:56 -0500 Subject: [PATCH 13/19] feat(paynym): add P2TR (taproot) payment address support --- lib/models/paynym/paynym_account_lite.dart | 10 +- .../paynym_interface.dart | 274 +++++++++--------- 2 files changed, 142 insertions(+), 142 deletions(-) diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart index 694efb778..33c6ebaa7 100644 --- a/lib/models/paynym/paynym_account_lite.dart +++ b/lib/models/paynym/paynym_account_lite.dart @@ -13,25 +13,29 @@ class PaynymAccountLite { final String nymName; final String code; final bool segwit; + final bool taproot; PaynymAccountLite( this.nymId, this.nymName, this.code, - this.segwit, - ); + this.segwit, { + this.taproot = false, + }); PaynymAccountLite.fromMap(Map map) : nymId = map["nymId"] as String, nymName = map["nymName"] as String, code = map["code"] as String, - segwit = map["segwit"] as bool; + segwit = map["segwit"] as bool, + taproot = map["taproot"] as bool? ?? false; Map toMap() => { "nymId": nymId, "nymName": nymName, "code": code, "segwit": segwit, + "taproot": taproot, }; @override diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index c184033df..5319edfd8 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -91,26 +91,31 @@ mixin PaynymInterface Future
currentReceivingPaynymAddress({ required PaymentCode sender, - required bool isSegwit, + required DerivePathType derivePathType, }) async { final keys = await lookupKey(sender.toString()); + final AddressType filterType; + switch (derivePathType) { + case DerivePathType.bip86: + filterType = AddressType.p2tr; + break; + case DerivePathType.bip84: + filterType = AddressType.p2wpkh; + break; + case DerivePathType.bip44: + default: + filterType = AddressType.p2pkh; + break; + } + final address = await mainDB .getAddresses(walletId) .filter() .subTypeEqualTo(AddressSubType.paynymReceive) .and() - .group((q) { - if (isSegwit) { - return q - .typeEqualTo(AddressType.p2sh) - .or() - .typeEqualTo(AddressType.p2wpkh); - } else { - return q.typeEqualTo(AddressType.p2pkh); - } - }) + .typeEqualTo(filterType) .and() .anyOf( keys, @@ -123,7 +128,7 @@ mixin PaynymInterface final generatedAddress = await _generatePaynymReceivingAddress( sender: sender, index: 0, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, ); final existing = @@ -134,23 +139,67 @@ mixin PaynymInterface .findFirst(); if (existing == null) { - // Add that new address await mainDB.putAddress(generatedAddress); } else { - // we need to update the address await mainDB.updateAddress(existing, generatedAddress); } - return currentReceivingPaynymAddress(isSegwit: isSegwit, sender: sender); + return currentReceivingPaynymAddress( + derivePathType: derivePathType, + sender: sender, + ); } else { return address; } } + /// Convert a compressed public key to a P2TR (taproot) address string. + String _pubKeyToP2TRAddress(Uint8List compressedPubKey) { + final ecPubKey = coinlib.ECPublicKey(compressedPubKey); + final taproot = coinlib.Taproot(internalKey: ecPubKey); + final addr = coinlib.P2TRAddress.fromTaproot( + taproot, + hrp: cryptoCurrency.networkParams.bech32Hrp, + ); + return addr.toString(); + } + + ({String address, AddressType type}) _paynymAddressAndType({ + required PaymentAddress paymentAddress, + required DerivePathType derivePathType, + required bool isSend, + }) { + switch (derivePathType) { + case DerivePathType.bip86: + final pubKey = isSend + ? paymentAddress.getDerivedSendPublicKey() + : paymentAddress.getDerivedReceivePublicKey(); + return ( + address: _pubKeyToP2TRAddress(pubKey), + type: isSend ? AddressType.nonWallet : AddressType.p2tr, + ); + case DerivePathType.bip84: + return ( + address: isSend + ? paymentAddress.getSendAddressP2WPKH() + : paymentAddress.getReceiveAddressP2WPKH(), + type: isSend ? AddressType.nonWallet : AddressType.p2wpkh, + ); + case DerivePathType.bip44: + default: + return ( + address: isSend + ? paymentAddress.getSendAddressP2PKH() + : paymentAddress.getReceiveAddressP2PKH(), + type: isSend ? AddressType.nonWallet : AddressType.p2pkh, + ); + } + } + Future
_generatePaynymReceivingAddress({ required PaymentCode sender, required int index, - required bool generateSegwitAddress, + required DerivePathType derivePathType, }) async { final root = await _getRootNode(); final node = root.derivePath( @@ -164,14 +213,15 @@ mixin PaynymInterface index: 0, ); - final addressString = - generateSegwitAddress - ? paymentAddress.getReceiveAddressP2WPKH() - : paymentAddress.getReceiveAddressP2PKH(); + final result = _paynymAddressAndType( + paymentAddress: paymentAddress, + derivePathType: derivePathType, + isSend: false, + ); final address = Address( walletId: walletId, - value: addressString, + value: result.address, publicKey: [], derivationIndex: index, derivationPath: @@ -180,7 +230,7 @@ mixin PaynymInterface index, testnet: info.coin.network.isTestNet, ), - type: generateSegwitAddress ? AddressType.p2wpkh : AddressType.p2pkh, + type: result.type, subType: AddressSubType.paynymReceive, otherData: await storeCode(sender.toString()), ); @@ -191,7 +241,7 @@ mixin PaynymInterface Future
_generatePaynymSendAddress({ required PaymentCode other, required int index, - required bool generateSegwitAddress, + required DerivePathType derivePathType, bip32.BIP32? mySendBip32Node, }) async { final node = mySendBip32Node ?? await deriveNotificationBip32Node(); @@ -203,14 +253,15 @@ mixin PaynymInterface index: index, ); - final addressString = - generateSegwitAddress - ? paymentAddress.getSendAddressP2WPKH() - : paymentAddress.getSendAddressP2PKH(); + final result = _paynymAddressAndType( + paymentAddress: paymentAddress, + derivePathType: derivePathType, + isSend: true, + ); final address = Address( walletId: walletId, - value: addressString, + value: result.address, publicKey: [], derivationIndex: index, derivationPath: @@ -219,7 +270,7 @@ mixin PaynymInterface index, testnet: info.coin.network.isTestNet, ), - type: AddressType.nonWallet, + type: result.type, subType: AddressSubType.paynymSend, otherData: await storeCode(other.toString()), ); @@ -229,11 +280,11 @@ mixin PaynymInterface Future checkCurrentPaynymReceivingAddressForTransactions({ required PaymentCode sender, - required bool isSegwit, + required DerivePathType derivePathType, }) async { final address = await currentReceivingPaynymAddress( sender: sender, - isSegwit: isSegwit, + derivePathType: derivePathType, ); final txCount = await fetchTxCount( @@ -246,7 +297,7 @@ mixin PaynymInterface final nextAddress = await _generatePaynymReceivingAddress( sender: sender, index: address.derivationIndex + 1, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, ); final existing = @@ -257,16 +308,14 @@ mixin PaynymInterface .findFirst(); if (existing == null) { - // Add that new address await mainDB.putAddress(nextAddress); } else { - // we need to update the address await mainDB.updateAddress(existing, nextAddress); } // keep checking until address with no tx history is set as current await checkCurrentPaynymReceivingAddressForTransactions( sender: sender, - isSegwit: isSegwit, + derivePathType: derivePathType, ); } } @@ -278,15 +327,23 @@ mixin PaynymInterface futures.add( checkCurrentPaynymReceivingAddressForTransactions( sender: code, - isSegwit: true, + derivePathType: DerivePathType.bip84, ), ); futures.add( checkCurrentPaynymReceivingAddressForTransactions( sender: code, - isSegwit: false, + derivePathType: DerivePathType.bip44, ), ); + if (code.isTaprootEnabled()) { + futures.add( + checkCurrentPaynymReceivingAddressForTransactions( + sender: code, + derivePathType: DerivePathType.bip86, + ), + ); + } } await Future.wait(futures); } @@ -386,10 +443,19 @@ mixin PaynymInterface ); } else { final myPrivateKeyNode = await deriveNotificationBip32Node(); + final DerivePathType sendDeriveType; + if (txData.paynymAccountLite!.taproot) { + sendDeriveType = DerivePathType.bip86; + } else if (txData.paynymAccountLite!.segwit) { + sendDeriveType = DerivePathType.bip84; + } else { + sendDeriveType = DerivePathType.bip44; + } + final sendToAddress = await nextUnusedSendAddressFrom( pCode: paymentCode, privateKeyNode: myPrivateKeyNode, - isSegwit: txData.paynymAccountLite!.segwit, + derivePathType: sendDeriveType, ); return prepareSend( @@ -411,7 +477,7 @@ mixin PaynymInterface /// and your own private key Future
nextUnusedSendAddressFrom({ required PaymentCode pCode, - required bool isSegwit, + required DerivePathType derivePathType, required bip32.BIP32 privateKeyNode, int startIndex = 0, }) async { @@ -448,7 +514,7 @@ mixin PaynymInterface final address = await _generatePaynymSendAddress( other: pCode, index: i, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, mySendBip32Node: privateKeyNode, ); @@ -1390,12 +1456,19 @@ mixin PaynymInterface final List> futures = []; for (final code in codes) { + final types = [DerivePathType.bip44]; + if (code.isSegWitEnabled()) { + types.add(DerivePathType.bip84); + } + if (code.isTaprootEnabled()) { + types.add(DerivePathType.bip86); + } futures.add( _restoreHistoryWith( other: code, maxUnusedAddressGap: maxUnusedAddressGap, maxNumberOfIndexesToCheck: maxNumberOfIndexesToCheck, - checkSegwitAsWell: code.isSegWitEnabled(), + derivePathTypes: types, ), ); } @@ -1405,144 +1478,67 @@ mixin PaynymInterface Future _restoreHistoryWith({ required PaymentCode other, - required bool checkSegwitAsWell, + required List derivePathTypes, required int maxUnusedAddressGap, required int maxNumberOfIndexesToCheck, }) async { - // https://en.bitcoin.it/wiki/BIP_0047#Path_levels const maxCount = 2147483647; assert(maxNumberOfIndexesToCheck < maxCount); final mySendBip32Node = await deriveNotificationBip32Node(); - final List
addresses = []; - int receivingGapCounter = 0; - int outgoingGapCounter = 0; - - // non segwit receiving - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - receivingGapCounter < maxUnusedAddressGap; - i++ - ) { - if (receivingGapCounter < maxUnusedAddressGap) { + + for (final derivePathType in derivePathTypes) { + int receivingGap = 0; + for ( + int i = 0; + i < maxNumberOfIndexesToCheck && receivingGap < maxUnusedAddressGap; + i++ + ) { final address = await _generatePaynymReceivingAddress( sender: other, index: i, - generateSegwitAddress: false, + derivePathType: derivePathType, ); - addresses.add(address); - final count = await fetchTxCount( addressScriptHash: cryptoCurrency.addressToScriptHash( address: address.value, ), ); - if (count > 0) { - receivingGapCounter = 0; + receivingGap = 0; } else { - receivingGapCounter++; + receivingGap++; } } - } - // non segwit sends - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && outgoingGapCounter < maxUnusedAddressGap; - i++ - ) { - if (outgoingGapCounter < maxUnusedAddressGap) { + int outgoingGap = 0; + for ( + int i = 0; + i < maxNumberOfIndexesToCheck && outgoingGap < maxUnusedAddressGap; + i++ + ) { final address = await _generatePaynymSendAddress( other: other, index: i, - generateSegwitAddress: false, + derivePathType: derivePathType, mySendBip32Node: mySendBip32Node, ); - addresses.add(address); - final count = await fetchTxCount( addressScriptHash: cryptoCurrency.addressToScriptHash( address: address.value, ), ); - if (count > 0) { - outgoingGapCounter = 0; + outgoingGap = 0; } else { - outgoingGapCounter++; + outgoingGap++; } } } - if (checkSegwitAsWell) { - int receivingGapCounterSegwit = 0; - int outgoingGapCounterSegwit = 0; - // segwit receiving - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - receivingGapCounterSegwit < maxUnusedAddressGap; - i++ - ) { - if (receivingGapCounterSegwit < maxUnusedAddressGap) { - final address = await _generatePaynymReceivingAddress( - sender: other, - index: i, - generateSegwitAddress: true, - ); - - addresses.add(address); - - final count = await fetchTxCount( - addressScriptHash: cryptoCurrency.addressToScriptHash( - address: address.value, - ), - ); - - if (count > 0) { - receivingGapCounterSegwit = 0; - } else { - receivingGapCounterSegwit++; - } - } - } - - // segwit sends - for ( - int i = 0; - i < maxNumberOfIndexesToCheck && - outgoingGapCounterSegwit < maxUnusedAddressGap; - i++ - ) { - if (outgoingGapCounterSegwit < maxUnusedAddressGap) { - final address = await _generatePaynymSendAddress( - other: other, - index: i, - generateSegwitAddress: true, - mySendBip32Node: mySendBip32Node, - ); - - addresses.add(address); - - final count = await fetchTxCount( - addressScriptHash: cryptoCurrency.addressToScriptHash( - address: address.value, - ), - ); - - if (count > 0) { - outgoingGapCounterSegwit = 0; - } else { - outgoingGapCounterSegwit++; - } - } - } - } await mainDB.updateOrPutAddresses(addresses); } From 783dbdeded3859cd0e93b0ad4d90732e96b77713 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 6 Apr 2026 14:52:28 -0500 Subject: [PATCH 14/19] TODO: merge https://github.com/cypherstack/bip47/pull/9 and update to main --- scripts/app_config/templates/pubspec.template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6b551d4c4..592bcd73f 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -104,8 +104,8 @@ dependencies: bip47: git: - url: https://github.com/cypherstack/bip47.git - ref: a6e7941b98a43a613708b1a12564bc17e712cfc7 + url: https://github.com/sneurlax/bip47.git + ref: 8ff94c4695e948891ab1e2c278c91679a1b0c8f0 fusiondart: git: From c648781ba434f9095feeec398b93142433b46527 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 21:11:11 -0500 Subject: [PATCH 15/19] feat(paynym): add isTaproot param to getPaymentCode and enable on claim --- lib/pages/paynym/paynym_claim_view.dart | 7 +++++-- .../wallet/wallet_mixin_interfaces/paynym_interface.dart | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index cb04fda4f..2f1bda128 100644 --- a/lib/pages/paynym/paynym_claim_view.dart +++ b/lib/pages/paynym/paynym_claim_view.dart @@ -192,8 +192,11 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; - // get payment code - final pCode = await wallet.getPaymentCode(isSegwit: false); + // get payment code with taproot + segwit feature bits + final pCode = await wallet.getPaymentCode( + isSegwit: true, + isTaproot: true, + ); if (shouldCancel) return; diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 5319edfd8..aae211923 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -374,7 +374,10 @@ mixin PaynymInterface } /// fetch or generate this wallet's bip47 payment code - Future getPaymentCode({required bool isSegwit}) async { + Future getPaymentCode({ + required bool isSegwit, + bool isTaproot = false, + }) async { final node = await _getRootNode(); final paymentCode = PaymentCode.fromBip32Node( @@ -382,7 +385,8 @@ mixin PaynymInterface _basePaynymDerivePath(testnet: info.coin.network.isTestNet), ), networkType: networkType, - shouldSetSegwitBit: isSegwit, + shouldSetSegwitBit: isSegwit || isTaproot, + shouldSetTaprootBit: isTaproot, ); return paymentCode; From 86432cd93e27e57094033fd91d5b7a940b887a5d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 21:13:34 -0500 Subject: [PATCH 16/19] feat(paynym): infer taproot capability from payment code feature byte --- lib/models/paynym/paynym_account_lite.dart | 21 ++++++++++++++++--- .../paynym_follow_toggle_button.dart | 3 +++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart index 33c6ebaa7..87cc03626 100644 --- a/lib/models/paynym/paynym_account_lite.dart +++ b/lib/models/paynym/paynym_account_lite.dart @@ -1,6 +1,6 @@ -/* +/* * This file is part of Stack Wallet. - * + * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. @@ -8,6 +8,9 @@ * */ +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; + class PaynymAccountLite { final String nymId; final String nymName; @@ -28,7 +31,19 @@ class PaynymAccountLite { nymName = map["nymName"] as String, code = map["code"] as String, segwit = map["segwit"] as bool, - taproot = map["taproot"] as bool? ?? false; + taproot = map["taproot"] as bool? ?? inferTaproot(map["code"] as String); + + static bool inferTaproot(String paymentCodeString) { + try { + final pCode = PaymentCode.fromPaymentCode( + paymentCodeString, + networkType: bitcoindart.bitcoin, + ); + return pCode.isTaprootEnabled(); + } catch (_) { + return false; + } + } Map toMap() => { "nymId": nymId, diff --git a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart index b512bbb88..24613bc56 100644 --- a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart +++ b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart @@ -141,6 +141,9 @@ class _PaynymFollowToggleButtonState followedAccount.value!.nymName, followedAccount.value!.nonSegwitPaymentCode.code, followedAccount.value!.segwit, + taproot: PaynymAccountLite.inferTaproot( + followedAccount.value!.nonSegwitPaymentCode.code, + ), ), ); From 783cb5e9aea468ea4872b45ef8c66d1fed08462f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 25 Mar 2026 21:20:38 -0500 Subject: [PATCH 17/19] test(paynym): add PaynymAccountLite taproot inference tests --- test/paynym_p2tr_test.dart | 120 +++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 test/paynym_p2tr_test.dart diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart new file mode 100644 index 000000000..c92c674bb --- /dev/null +++ b/test/paynym_p2tr_test.dart @@ -0,0 +1,120 @@ +import 'package:bip32/bip32.dart' as bip32; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; +import 'package:stackwallet/models/paynym/paynym_account_lite.dart'; +import 'package:test/test.dart'; + +void main() { + const mnemonic = + 'response seminar brave million suit skate inhale proud weapon daring champion'; + + final networkType = bip32.NetworkType( + wif: bitcoindart.bitcoin.wif, + bip32: bip32.Bip32Type( + public: bitcoindart.bitcoin.bip32.public, + private: bitcoindart.bitcoin.bip32.private, + ), + ); + + late String v1PaymentCodeString; + late String taprootPaymentCodeString; + + setUpAll(() { + final seed = bip39.mnemonicToSeed(mnemonic); + final root = bip32.BIP32.fromSeed(seed, networkType); + final paymentCodeNode = root.derivePath("m/47'/0'/0'"); + + // Build a standard v1 payment code (no taproot, no segwit). + final v1Code = PaymentCode.fromBip32Node( + paymentCodeNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + v1PaymentCodeString = v1Code.toString(); + + // Build a taproot-enabled payment code. + final taprootCode = PaymentCode.fromBip32Node( + paymentCodeNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + shouldSetTaprootBit: true, + ); + taprootPaymentCodeString = taprootCode.toString(); + }); + + group('PaynymAccountLite taproot inference', () { + test('inferTaproot returns true for taproot-enabled payment code', () { + final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString); + expect(result, isTrue); + }); + + test('inferTaproot returns false for standard v1 payment code', () { + final result = PaynymAccountLite.inferTaproot(v1PaymentCodeString); + expect(result, isFalse); + }); + + test('inferTaproot returns false for invalid payment code string', () { + final result = PaynymAccountLite.inferTaproot('not-a-payment-code'); + expect(result, isFalse); + }); + }); + + group('PaynymAccountLite.fromMap taproot inference', () { + test( + 'fromMap infers taproot=true when taproot key is absent ' + 'but payment code has taproot bit set', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': taprootPaymentCodeString, + 'segwit': true, + // No 'taproot' key — should be inferred from the code. + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isTrue); + }); + + test( + 'fromMap infers taproot=false when taproot key is absent ' + 'and payment code does not have taproot bit set', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': v1PaymentCodeString, + 'segwit': false, + // No 'taproot' key — should be inferred from the code. + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isFalse); + }); + + test('fromMap uses explicit taproot=true from map when provided', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': v1PaymentCodeString, + 'segwit': false, + 'taproot': true, + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isTrue); + }); + + test('fromMap uses explicit taproot=false from map when provided', () { + final map = { + 'nymId': 'test-id', + 'nymName': 'test-name', + 'code': taprootPaymentCodeString, + 'segwit': true, + 'taproot': false, + }; + + final account = PaynymAccountLite.fromMap(map); + expect(account.taproot, isFalse); + }); + }); +} From eebee9ba91af723622281882c7bef37e90833d0a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 12 May 2026 21:06:41 -0500 Subject: [PATCH 18/19] chore: update ref back to cypherstack#main as of bdc0c0788d1d6dfb04863a793955f848ba1624a8 see 783dbdeded3859cd0e93b0ad4d90732e96b77713 --- scripts/app_config/templates/pubspec.template.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index dca1fb6bc..c5678724b 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -104,8 +104,8 @@ dependencies: bip47: git: - url: https://github.com/sneurlax/bip47.git - ref: 8ff94c4695e948891ab1e2c278c91679a1b0c8f0 + url: https://github.com/cypherstack/bip47.git + ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 fusiondart: git: @@ -323,7 +323,7 @@ dependency_overrides: bip47: git: url: https://github.com/cypherstack/bip47.git - ref: 3ef6b94375d7b4d972b0bc0bd9597532381a88ec + ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 # bip47 pins a different bitcoindart commit; override to ours bitcoindart: From 5f9ca3a4ba43c71c361c6ad310e97266195ec699 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 13 May 2026 16:01:05 -0500 Subject: [PATCH 19/19] chore: dart format --- lib/models/paynym/paynym_account_lite.dart | 22 +- lib/models/paynym/paynym_claim.dart | 9 +- lib/pages/paynym/paynym_claim_view.dart | 120 +++---- .../paynym_interface.dart | 318 ++++++++---------- .../paynym_follow_toggle_button.dart | 93 ++--- test/paynym_p2tr_test.dart | 6 +- 6 files changed, 257 insertions(+), 311 deletions(-) diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart index 87cc03626..4702be49e 100644 --- a/lib/models/paynym/paynym_account_lite.dart +++ b/lib/models/paynym/paynym_account_lite.dart @@ -27,11 +27,11 @@ class PaynymAccountLite { }); PaynymAccountLite.fromMap(Map map) - : nymId = map["nymId"] as String, - nymName = map["nymName"] as String, - code = map["code"] as String, - segwit = map["segwit"] as bool, - taproot = map["taproot"] as bool? ?? inferTaproot(map["code"] as String); + : nymId = map["nymId"] as String, + nymName = map["nymName"] as String, + code = map["code"] as String, + segwit = map["segwit"] as bool, + taproot = map["taproot"] as bool? ?? inferTaproot(map["code"] as String); static bool inferTaproot(String paymentCodeString) { try { @@ -46,12 +46,12 @@ class PaynymAccountLite { } Map toMap() => { - "nymId": nymId, - "nymName": nymName, - "code": code, - "segwit": segwit, - "taproot": taproot, - }; + "nymId": nymId, + "nymName": nymName, + "code": code, + "segwit": segwit, + "taproot": taproot, + }; @override String toString() { diff --git a/lib/models/paynym/paynym_claim.dart b/lib/models/paynym/paynym_claim.dart index 36afef7bd..e2733710b 100644 --- a/lib/models/paynym/paynym_claim.dart +++ b/lib/models/paynym/paynym_claim.dart @@ -15,13 +15,10 @@ class PaynymClaim { PaynymClaim(this.claimed, this.token); PaynymClaim.fromMap(Map map) - : claimed = map["claimed"].toString(), - token = map["token"] as String; + : claimed = map["claimed"].toString(), + token = map["token"] as String; - Map toMap() => { - "claimed": claimed, - "token": token, - }; + Map toMap() => {"claimed": claimed, "token": token}; @override String toString() { diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index 2f1bda128..f29c50eaa 100644 --- a/lib/pages/paynym/paynym_claim_view.dart +++ b/lib/pages/paynym/paynym_claim_view.dart @@ -32,10 +32,7 @@ import 'dialogs/claiming_paynym_dialog.dart'; import 'paynym_home_view.dart'; class PaynymClaimView extends ConsumerStatefulWidget { - const PaynymClaimView({ - super.key, - required this.walletId, - }); + const PaynymClaimView({super.key, required this.walletId}); final String walletId; @@ -80,23 +77,20 @@ class _PaynymClaimViewState extends ConsumerState { leading: Row( children: [ Padding( - padding: const EdgeInsets.only( - left: 24, - right: 20, - ), + padding: const EdgeInsets.only(left: 24, right: 20), child: AppBarIconButton( size: 32, - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of(context) - .extension()! - .topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -107,13 +101,8 @@ class _PaynymClaimViewState extends ConsumerState { height: 42, color: Theme.of(context).extension()!.textDark, ), - const SizedBox( - width: 10, - ), - Text( - "PayNym", - style: STextStyles.desktopH3(context), - ), + const SizedBox(width: 10), + Text("PayNym", style: STextStyles.desktopH3(context)), ], ), ) @@ -129,52 +118,36 @@ class _PaynymClaimViewState extends ConsumerState { body: ConditionalParent( condition: !isDesktop, builder: (child) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), + child: Padding(padding: const EdgeInsets.all(16), child: child), ), child: ConditionalParent( condition: isDesktop, - builder: (child) => SizedBox( - width: 328, - child: child, - ), + builder: (child) => SizedBox(width: 328, child: child), child: Column( children: [ - const Spacer( - flex: 1, - ), + const Spacer(flex: 1), SvgPicture.asset( Assets.svg.unclaimedPaynym, width: MediaQuery.of(context).size.width / 2, ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), Text( "You do not have a PayNym yet.\nClaim yours now!", style: isDesktop ? STextStyles.desktopSubtitleH2(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ) : STextStyles.baseXS(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), textAlign: TextAlign.center, ), - if (isDesktop) - const SizedBox( - height: 30, - ), - if (!isDesktop) - const Spacer( - flex: 2, - ), + if (isDesktop) const SizedBox(height: 30), + if (!isDesktop) const Spacer(flex: 2), PrimaryButton( label: "Claim", onPressed: () async { @@ -187,8 +160,9 @@ class _PaynymClaimViewState extends ConsumerState { ).then((value) => shouldCancel = value == true), ); - final wallet = ref.read(pWallets).getWallet(widget.walletId) - as PaynymInterface; + final wallet = + ref.read(pWallets).getWallet(widget.walletId) + as PaynymInterface; if (shouldCancel) return; @@ -225,11 +199,9 @@ class _PaynymClaimViewState extends ConsumerState { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); } else { - Navigator.of(context).popUntil( - ModalRoute.withName( - WalletView.routeName, - ), - ); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); } await Navigator.of(context).pushNamed( PaynymHomeView.routeName, @@ -240,11 +212,9 @@ class _PaynymClaimViewState extends ConsumerState { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); } else { - Navigator.of(context).popUntil( - ModalRoute.withName( - WalletView.routeName, - ), - ); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); } } return; @@ -252,8 +222,9 @@ class _PaynymClaimViewState extends ConsumerState { if (shouldCancel) return; - final token = - await ref.read(paynymAPIProvider).token(pCode.toString()); + final token = await ref + .read(paynymAPIProvider) + .token(pCode.toString()); debugPrint("token result: $token"); @@ -268,8 +239,9 @@ class _PaynymClaimViewState extends ConsumerState { } // sign token with notification private key - final signature = - await wallet.signStringWithNotificationKey(token.value!); + final signature = await wallet.signStringWithNotificationKey( + token.value!, + ); debugPrint("signature: $signature"); @@ -287,8 +259,9 @@ class _PaynymClaimViewState extends ConsumerState { if (claim.statusCode == 200 || claim.value?.claimed == pCode.toString() || claim.value?.claimed == "true") { - final account = - await ref.read(paynymAPIProvider).nym(pCode.toString()); + final account = await ref + .read(paynymAPIProvider) + .nym(pCode.toString()); // if (!account.value!.segwit) { // for (int i = 0; i < 100; i++) { // final result = await _addSegwitCode(account.value!); @@ -305,11 +278,9 @@ class _PaynymClaimViewState extends ConsumerState { Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context).pop(); } else { - Navigator.of(context).popUntil( - ModalRoute.withName( - WalletView.routeName, - ), - ); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(WalletView.routeName)); } await Navigator.of(context).pushNamed( PaynymHomeView.routeName, @@ -328,10 +299,7 @@ class _PaynymClaimViewState extends ConsumerState { } }, ), - if (isDesktop) - const Spacer( - flex: 2, - ), + if (isDesktop) const Spacer(flex: 2), ], ), ), diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index aae211923..6c815ec12 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -109,20 +109,16 @@ mixin PaynymInterface break; } - final address = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymReceive) - .and() - .typeEqualTo(filterType) - .and() - .anyOf( - keys, - (q, String e) => q.otherDataEqualTo(e), - ) - .sortByDerivationIndexDesc() - .findFirst(); + final address = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymReceive) + .and() + .typeEqualTo(filterType) + .and() + .anyOf(keys, (q, String e) => q.otherDataEqualTo(e)) + .sortByDerivationIndexDesc() + .findFirst(); if (address == null) { final generatedAddress = await _generatePaynymReceivingAddress( @@ -131,12 +127,11 @@ mixin PaynymInterface derivePathType: derivePathType, ); - final existing = - await mainDB - .getAddresses(walletId) - .filter() - .valueEqualTo(generatedAddress.value) - .findFirst(); + final existing = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(generatedAddress.value) + .findFirst(); if (existing == null) { await mainDB.putAddress(generatedAddress); @@ -224,12 +219,11 @@ mixin PaynymInterface value: result.address, publicKey: [], derivationIndex: index, - derivationPath: - DerivationPath() - ..value = _receivingPaynymAddressDerivationPath( - index, - testnet: info.coin.network.isTestNet, - ), + derivationPath: DerivationPath() + ..value = _receivingPaynymAddressDerivationPath( + index, + testnet: info.coin.network.isTestNet, + ), type: result.type, subType: AddressSubType.paynymReceive, otherData: await storeCode(sender.toString()), @@ -264,12 +258,11 @@ mixin PaynymInterface value: result.address, publicKey: [], derivationIndex: index, - derivationPath: - DerivationPath() - ..value = _sendPaynymAddressDerivationPath( - index, - testnet: info.coin.network.isTestNet, - ), + derivationPath: DerivationPath() + ..value = _sendPaynymAddressDerivationPath( + index, + testnet: info.coin.network.isTestNet, + ), type: result.type, subType: AddressSubType.paynymSend, otherData: await storeCode(other.toString()), @@ -300,12 +293,11 @@ mixin PaynymInterface derivePathType: derivePathType, ); - final existing = - await mainDB - .getAddresses(walletId) - .filter() - .valueEqualTo(nextAddress.value) - .findFirst(); + final existing = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(nextAddress.value) + .findFirst(); if (existing == null) { await mainDB.putAddress(nextAddress); @@ -409,12 +401,9 @@ mixin PaynymInterface // Clean prefix: strip leading length byte if present (coinlib recalculates) final prefixBytes = cryptoCurrency.networkParams.messagePrefix.toUint8ListFromUtf8; - final ignoreFirstByte = - prefixBytes.first == prefixBytes.length - 1; - final prefix = (ignoreFirstByte - ? prefixBytes.sublist(1) - : prefixBytes) - .toUtf8String; + final ignoreFirstByte = prefixBytes.first == prefixBytes.length - 1; + final prefix = + (ignoreFirstByte ? prefixBytes.sublist(1) : prefixBytes).toUtf8String; final signed = coinlib.MessageSignature.sign( key: key, @@ -490,19 +479,15 @@ mixin PaynymInterface for (int i = startIndex; i < maxCount; i++) { final keys = await lookupKey(pCode.toString()); - final address = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymSend) - .and() - .anyOf( - keys, - (q, String e) => q.otherDataEqualTo(e), - ) - .and() - .derivationIndexEqualTo(i) - .findFirst(); + final address = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymSend) + .and() + .anyOf(keys, (q, String e) => q.otherDataEqualTo(e)) + .and() + .derivationIndexEqualTo(i) + .findFirst(); if (address != null) { final count = await fetchTxCount( @@ -586,9 +571,11 @@ mixin PaynymInterface // end since taproot inputs don't expose the raw public key needed by the // receiver to compute ECDH for BIP47 notification parsing. spendableOutputs.sort((a, b) { - final aIsTaproot = a.address?.startsWith('bc1p') == true || + final aIsTaproot = + a.address?.startsWith('bc1p') == true || a.address?.startsWith('tb1p') == true; - final bIsTaproot = b.address?.startsWith('bc1p') == true || + final bIsTaproot = + b.address?.startsWith('bc1p') == true || b.address?.startsWith('tb1p') == true; if (aIsTaproot != bIsTaproot) { return aIsTaproot ? 1 : -1; @@ -624,10 +611,9 @@ mixin PaynymInterface } // gather required signing data - final inputsWithKeys = - (await addSigningKeys( - utxoObjectsToUse.map((e) => StandardInput(e)).toList(), - )).whereType().toList(); + final inputsWithKeys = (await addSigningKeys( + utxoObjectsToUse.map((e) => StandardInput(e)).toList(), + )).whereType().toList(); final vSizeForNoChange = BigInt.from( (await _createNotificationTx( @@ -923,8 +909,8 @@ mixin PaynymInterface clTx = clTx.addInput(input); } - final String notificationAddress = - targetPaymentCode.notificationAddressP2PKH(); + final String notificationAddress = targetPaymentCode + .notificationAddressP2PKH(); final address = coinlib.Address.fromString( normalizeAddress(notificationAddress), @@ -1092,13 +1078,12 @@ mixin PaynymInterface final myNotificationAddress = await getMyNotificationAddress(); - final txns = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .findAll(); + final txns = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .findAll(); for (final tx in txns) { switch (tx.type) { @@ -1132,15 +1117,14 @@ mixin PaynymInterface case TransactionType.outgoing: for (final output in tx.outputs) { for (final outputAddress in output.addresses) { - final address = - await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .valueEqualTo(outputAddress) - .findFirst(); + final address = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .valueEqualTo(outputAddress) + .findFirst(); if (address?.otherData != null) { final code = await paymentCodeStringByKey(address!.otherData!); @@ -1194,8 +1178,8 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = - designatedInput.outpoint!.txid.toUint8ListFromHex.reversed.toList(); + final txPoint = designatedInput.outpoint!.txid.toUint8ListFromHex.reversed + .toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); @@ -1258,8 +1242,8 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = - designatedInput.outpoint!.txid.toUint8ListFromHex.toList(); + final txPoint = designatedInput.outpoint!.txid.toUint8ListFromHex + .toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); @@ -1309,13 +1293,12 @@ mixin PaynymInterface Future> getAllPaymentCodesFromNotificationTransactions() async { - final txns = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .findAll(); + final txns = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .findAll(); final List codes = []; @@ -1326,15 +1309,14 @@ mixin PaynymInterface for (final outputAddress in output.addresses.where( (e) => e.isNotEmpty, )) { - final address = - await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .valueEqualTo(outputAddress) - .findFirst(); + final address = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .valueEqualTo(outputAddress) + .findFirst(); if (address?.otherData != null) { final codeString = await paymentCodeStringByKey( @@ -1380,15 +1362,14 @@ mixin PaynymInterface Future checkForNotificationTransactionsTo( Set otherCodeStrings, ) async { - final sentNotificationTransactions = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .subTypeEqualTo(TransactionSubType.bip47Notification) - .and() - .typeEqualTo(TransactionType.outgoing) - .findAll(); + final sentNotificationTransactions = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .subTypeEqualTo(TransactionSubType.bip47Notification) + .and() + .typeEqualTo(TransactionType.outgoing) + .findAll(); final List codes = []; for (final codeString in otherCodeStrings) { @@ -1547,17 +1528,16 @@ mixin PaynymInterface } Future
getMyNotificationAddress() async { - final storedAddress = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .typeEqualTo(AddressType.p2pkh) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .findFirst(); + final storedAddress = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .typeEqualTo(AddressType.p2pkh) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .findFirst(); if (storedAddress != null) { return storedAddress; @@ -1576,19 +1556,20 @@ mixin PaynymInterface pubkey: paymentCode.notificationPublicKey(), ); - final addressString = - btc_dart.P2PKH(data: data, network: networkType).data.address!; + final addressString = btc_dart + .P2PKH(data: data, network: networkType) + .data + .address!; Address address = Address( walletId: walletId, value: addressString, publicKey: paymentCode.getPubKey(), derivationIndex: 0, - derivationPath: - DerivationPath() - ..value = _notificationDerivationPath( - testnet: info.coin.network.isTestNet, - ), + derivationPath: DerivationPath() + ..value = _notificationDerivationPath( + testnet: info.coin.network.isTestNet, + ), type: AddressType.p2pkh, subType: AddressSubType.paynymNotification, otherData: await storeCode(paymentCode.toString()), @@ -1599,17 +1580,16 @@ mixin PaynymInterface // beginning to see if there already was notification address. This would // lead to a Unique Index violation error await mainDB.isar.writeTxn(() async { - final storedAddress = - await mainDB - .getAddresses(walletId) - .filter() - .subTypeEqualTo(AddressSubType.paynymNotification) - .and() - .typeEqualTo(AddressType.p2pkh) - .and() - .not() - .typeEqualTo(AddressType.nonWallet) - .findFirst(); + final storedAddress = await mainDB + .getAddresses(walletId) + .filter() + .subTypeEqualTo(AddressSubType.paynymNotification) + .and() + .typeEqualTo(AddressType.p2pkh) + .and() + .not() + .typeEqualTo(AddressType.nonWallet) + .findFirst(); if (storedAddress == null) { await mainDB.isar.addresses.put(address); @@ -1687,21 +1667,19 @@ mixin PaynymInterface overrideAddresses ?? await fetchAddressesForElectrumXScan(); // Separate receiving and change addresses. - final Set receivingAddresses = - allAddressesOld - .where( - (e) => - e.subType == AddressSubType.receiving || - e.subType == AddressSubType.paynymNotification || - e.subType == AddressSubType.paynymReceive, - ) - .map((e) => e.value) - .toSet(); - final Set changeAddresses = - allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); + final Set receivingAddresses = allAddressesOld + .where( + (e) => + e.subType == AddressSubType.receiving || + e.subType == AddressSubType.paynymNotification || + e.subType == AddressSubType.paynymReceive, + ) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; @@ -1711,16 +1689,15 @@ mixin PaynymInterface allAddressesSet, ); - final unconfirmedTxs = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .heightIsNull() - .or() - .heightEqualTo(0) - .txidProperty() - .findAll(); + final unconfirmedTxs = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNull() + .or() + .heightEqualTo(0) + .txidProperty() + .findAll(); allTxHashes.addAll(unconfirmedTxs.map((e) => {"tx_hash": e})); @@ -1752,13 +1729,12 @@ mixin PaynymInterface "'message': 'No such mempool or blockchain transaction", )) { await mainDB.isar.writeTxn( - () async => - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .txidEqualTo(txid) - .deleteFirst(), + () async => await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txid) + .deleteFirst(), ); continue; } else { diff --git a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart index 24613bc56..c2a4c6cb5 100644 --- a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart +++ b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart @@ -29,11 +29,7 @@ import '../desktop/primary_button.dart'; import '../desktop/secondary_button.dart'; import '../loading_indicator.dart'; -enum PaynymFollowToggleButtonStyle { - primary, - detailsPopup, - detailsDesktop, -} +enum PaynymFollowToggleButtonStyle { primary, detailsPopup, detailsDesktop } const kDisableFollowing = false; @@ -63,12 +59,8 @@ class _PaynymFollowToggleButtonState unawaited( showDialog( context: context, - builder: (context) => const LoadingIndicator( - width: 200, - ), - ).then( - (_) => loadingPopped = true, - ), + builder: (context) => const LoadingIndicator(width: 200), + ).then((_) => loadingPopped = true), ); // get wallet to access paynym calls @@ -81,29 +73,35 @@ class _PaynymFollowToggleButtonState final myPCode = await wallet.getPaymentCode(isSegwit: false); - PaynymResponse token = - await ref.read(paynymAPIProvider).token(myPCode.toString()); + PaynymResponse token = await ref + .read(paynymAPIProvider) + .token(myPCode.toString()); // sign token with notification private key String signature = await wallet.signStringWithNotificationKey(token.value!); - var result = await ref.read(paynymAPIProvider).follow( + var result = await ref + .read(paynymAPIProvider) + .follow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, ); int i = 0; - for (; - i < 10 && - result.statusCode == 401; //"401 Unauthorized - Bad signature"; - i++) { + for ( + ; + i < 10 && result.statusCode == 401; //"401 Unauthorized - Bad signature"; + i++ + ) { token = await ref.read(paynymAPIProvider).token(myPCode.toString()); // sign token with notification private key signature = await wallet.signStringWithNotificationKey(token.value!); - result = await ref.read(paynymAPIProvider).follow( + result = await ref + .read(paynymAPIProvider) + .follow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, @@ -115,7 +113,8 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Follow result: $result on try $i"); - final followSuccess = result.statusCode == 200 || + final followSuccess = + result.statusCode == 200 || result.value?.following == followedAccount.value?.nymID; if (followSuccess && followedAccount.value != null) { @@ -163,7 +162,8 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to follow ${followedAccount.value?.nymName ?? "PayNym"}", + message: + "Failed to follow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); @@ -178,12 +178,8 @@ class _PaynymFollowToggleButtonState unawaited( showDialog( context: context, - builder: (context) => const LoadingIndicator( - width: 200, - ), - ).then( - (_) => loadingPopped = true, - ), + builder: (context) => const LoadingIndicator(width: 200), + ).then((_) => loadingPopped = true), ); final wallet = @@ -195,29 +191,35 @@ class _PaynymFollowToggleButtonState final myPCode = await wallet.getPaymentCode(isSegwit: false); - PaynymResponse token = - await ref.read(paynymAPIProvider).token(myPCode.toString()); + PaynymResponse token = await ref + .read(paynymAPIProvider) + .token(myPCode.toString()); // sign token with notification private key String signature = await wallet.signStringWithNotificationKey(token.value!); - var result = await ref.read(paynymAPIProvider).unfollow( + var result = await ref + .read(paynymAPIProvider) + .unfollow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, ); int i = 0; - for (; - i < 10 && - result.statusCode == 401; //"401 Unauthorized - Bad signature"; - i++) { + for ( + ; + i < 10 && result.statusCode == 401; //"401 Unauthorized - Bad signature"; + i++ + ) { token = await ref.read(paynymAPIProvider).token(myPCode.toString()); // sign token with notification private key signature = await wallet.signStringWithNotificationKey(token.value!); - result = await ref.read(paynymAPIProvider).unfollow( + result = await ref + .read(paynymAPIProvider) + .unfollow( token.value!, signature, followedAccount.value!.nonSegwitPaymentCode.code, @@ -228,7 +230,8 @@ class _PaynymFollowToggleButtonState Logging.instance.d("Unfollow result: $result on try $i"); - final unfollowSuccess = result.statusCode == 200 || + final unfollowSuccess = + result.statusCode == 200 || result.value?.unfollowing == followedAccount.value?.nymID; if (unfollowSuccess && followedAccount.value != null) { @@ -248,8 +251,9 @@ class _PaynymFollowToggleButtonState final myAccount = ref.read(myPaynymAccountStateProvider.state).state!; - myAccount.following - .removeWhere((e) => e.nymId == followedAccount.value!.nymID); + myAccount.following.removeWhere( + (e) => e.nymId == followedAccount.value!.nymID, + ); ref.read(myPaynymAccountStateProvider.state).state = myAccount.copyWith(); @@ -267,7 +271,8 @@ class _PaynymFollowToggleButtonState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Failed to unfollow ${followedAccount.value?.nymName ?? "PayNym"}", + message: + "Failed to unfollow ${followedAccount.value?.nymName ?? "PayNym"}", context: context, ), ); @@ -323,8 +328,9 @@ class _PaynymFollowToggleButtonState isFollowing ? Assets.svg.userMinus : Assets.svg.userPlus, width: 16, height: 16, - color: - Theme.of(context).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: kDisableFollowing ? null : _onPressed, ); @@ -337,8 +343,9 @@ class _PaynymFollowToggleButtonState isFollowing ? Assets.svg.userMinus : Assets.svg.userPlus, width: 16, height: 16, - color: - Theme.of(context).extension()!.buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), iconSpacing: 6, onPressed: kDisableFollowing ? null : _onPressed, diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart index c92c674bb..e4fd1abb0 100644 --- a/test/paynym_p2tr_test.dart +++ b/test/paynym_p2tr_test.dart @@ -61,8 +61,7 @@ void main() { }); group('PaynymAccountLite.fromMap taproot inference', () { - test( - 'fromMap infers taproot=true when taproot key is absent ' + test('fromMap infers taproot=true when taproot key is absent ' 'but payment code has taproot bit set', () { final map = { 'nymId': 'test-id', @@ -76,8 +75,7 @@ void main() { expect(account.taproot, isTrue); }); - test( - 'fromMap infers taproot=false when taproot key is absent ' + test('fromMap infers taproot=false when taproot key is absent ' 'and payment code does not have taproot bit set', () { final map = { 'nymId': 'test-id',