diff --git a/lib/models/paynym/paynym_claim.dart b/lib/models/paynym/paynym_claim.dart index 0f1e66373a..36afef7bd1 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() => { diff --git a/lib/pages/paynym/paynym_claim_view.dart b/lib/pages/paynym/paynym_claim_view.dart index 8d61e139c5..4eb3c9b936 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(); } }, diff --git a/lib/utilities/paynym_is_api.dart b/lib/utilities/paynym_is_api.dart index 9285fef7f6..5230f1734e 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` diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 0d993036d3..bb6958418d 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({ diff --git a/scripts/linux/build_secp256k1.sh b/scripts/linux/build_secp256k1.sh index e139cc9377..b6037a3060 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" diff --git a/scripts/windows/build_secp256k1.bat b/scripts/windows/build_secp256k1.bat index bae7c97888..b619e6e78e 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\" diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh index a39cd3bee3..cedb2bc2c1 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" 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 0000000000..fa2e651a2e --- /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 0000000000..e3d6837fa8 --- /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>); +}