diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart index 694efb7788..87cc036261 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,30 +8,49 @@ * */ +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; + class PaynymAccountLite { final String nymId; 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? ?? 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, "nymName": nymName, "code": code, "segwit": segwit, + "taproot": taproot, }; @override 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..2f1bda128d 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; @@ -206,21 +209,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(); @@ -240,12 +255,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 +280,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.statusCode == 200 || + claim.value?.claimed == pCode.toString() || + claim.value?.claimed == "true") { final account = await ref.read(paynymAPIProvider).nym(pCode.toString()); // if (!account.value!.segwit) { @@ -286,6 +317,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..9aafb7136c 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` @@ -357,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"; } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 0d993036d3..aae2119237 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); } @@ -317,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( @@ -325,7 +385,8 @@ mixin PaynymInterface _basePaynymDerivePath(testnet: info.coin.network.isTestNet), ), networkType: networkType, - shouldSetSegwitBit: isSegwit, + shouldSetSegwitBit: isSegwit || isTaproot, + shouldSetTaprootBit: isTaproot, ); return paymentCode; @@ -342,10 +403,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({ @@ -370,10 +447,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( @@ -395,7 +481,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 { @@ -432,7 +518,7 @@ mixin PaynymInterface final address = await _generatePaynymSendAddress( other: pCode, index: i, - generateSegwitAddress: isSegwit, + derivePathType: derivePathType, mySendBip32Node: privateKeyNode, ); @@ -496,8 +582,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; @@ -1106,7 +1203,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!; @@ -1165,7 +1267,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!; @@ -1353,12 +1460,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, ), ); } @@ -1368,144 +1482,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); } diff --git a/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart b/lib/widgets/custom_buttons/paynym_follow_toggle_button.dart index a2c4db1003..24613bc56f 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(); } @@ -138,6 +141,9 @@ class _PaynymFollowToggleButtonState followedAccount.value!.nymName, followedAccount.value!.nonSegwitPaymentCode.code, followedAccount.value!.segwit, + taproot: PaynymAccountLite.inferTaproot( + followedAccount.value!.nonSegwitPaymentCode.code, + ), ), ); @@ -157,7 +163,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 +228,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 +267,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, ), ); diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6b551d4c48..592bcd73fb 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: 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/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart new file mode 100644 index 0000000000..c92c674bb3 --- /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); + }); + }); +} 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>); +}