Skip to content
2 changes: 1 addition & 1 deletion lib/models/paynym/paynym_claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class PaynymClaim {
PaynymClaim(this.claimed, this.token);

PaynymClaim.fromMap(Map<String, dynamic> map)
: claimed = map["claimed"] as String,
: claimed = map["claimed"].toString(),
token = map["token"] as String;

Map<String, dynamic> toMap() => {
Expand Down
25 changes: 24 additions & 1 deletion lib/pages/paynym/paynym_claim_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -240,22 +240,38 @@ class _PaynymClaimViewState extends ConsumerState<PaynymClaimView> {
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
final claim = await ref
.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) {
Expand Down Expand Up @@ -286,6 +302,13 @@ class _PaynymClaimViewState extends ConsumerState<PaynymClaimView> {
);
}
} 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();
}
},
Expand Down
14 changes: 10 additions & 4 deletions lib/utilities/paynym_is_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>,
response.code,
);
Map<String, dynamic> parsedBody;
try {
final bodyStr = response.body.trim();
parsedBody = bodyStr.isEmpty
? {}
: jsonDecode(bodyStr) as Map<String, dynamic>;
} catch (_) {
parsedBody = {};
}
return Tuple2(parsedBody, response.code);
}

// ### `/api/v1/create`
Expand Down
22 changes: 19 additions & 3 deletions lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,26 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
}

Future<String> 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<TxData> preparePaymentCodeSend({
Expand Down
3 changes: 2 additions & 1 deletion scripts/linux/build_secp256k1.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion scripts/windows/build_secp256k1.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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\"
Expand Down
3 changes: 2 additions & 1 deletion scripts/windows/build_secp256k1_wsl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
245 changes: 245 additions & 0 deletions test/services/paynym/paynym_is_api_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, String>? 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': <Map<String, dynamic>>[],
'following': <Map<String, dynamic>>[],
}),
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);
});
});
}
Loading
Loading