What changed, and why it matters
This commit adds a safety check to prevent a Bitcoin PayNym notification transaction from being built using only Taproot (BIP86) inputs. The new code throws an error if the first input is Taproot, while allowing mixed or non-Taproot inputs. PayNym notification transactions have specific protocol requirements, and using an all-Taproot input set could lead to an invalid or incompatible notification that another wallet might not recognize, potentially causing loss of privacy or failed payments. The change is defensive and includes a unit test.
Review whether checking only `inputs.first` is sufficient. If the protocol requires at least one non-Taproot input anywhere in the transaction, the validation should iterate over all inputs. Also confirm that the intended behavior is to allow a Taproot input as long as it is not first, and document the PayNym notification input requirements in code comments or developer docs.
Security signals we found
Defensive input validation added to a BIP47 PayNym notification transaction flow
Rejects BIP86/Taproot as the sole/first input for a notification transaction
Includes unit test covering rejection and mixed-input acceptance cases
Partial validation: only checks the first input, not all inputs
Evidence from the diff
A new function validatePaynymNotificationInputs is introduced in paynym_interface.dart. It inspects inputs.first.derivePathType and throws PaynymSendException when it equals DerivePathType.bip86 (Taproot). The function is called immediately after inputsWithKeys is built during notification transaction construction. A test in test/paynym_p2tr_test.dart verifies that a single Taproot input is rejected and that a mixed [segwit, taproot] list is accepted. The check is only on the first input, not on the entire input list, so a Taproot input in position two or later would pass.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.darttest/paynym_p2tr_test.dartInspect captured patch +40 / −0
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
index 3789555..a38df01 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
@@ -60,6 +60,15 @@ int comparePaynymNotificationUtxos(UTXO a, UTXO b) {
return a.blockTime!.compareTo(b.blockTime!);
}
+@visibleForTesting
+void validatePaynymNotificationInputs(List<StandardInput> inputs) {
+ if (inputs.first.derivePathType == DerivePathType.bip86) {
+ throw PaynymSendException(
+ "A non-Taproot UTXO is required for a PayNym notification transaction.",
+ );
+ }
+}
+
mixin PaynymInterface<T extends PaynymCurrencyInterface>
on Bip39HDWallet<T>, ElectrumXInterface<T> {
btc_dart.NetworkType get networkType => btc_dart.NetworkType(
@@ -618,6 +627,8 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
utxoObjectsToUse.map((e) => StandardInput(e)).toList(),
)).whereType<StandardInput>().toList();
+ validatePaynymNotificationInputs(inputsWithKeys);
+
final vSizeForNoChange = BigInt.from(
(await _createNotificationTx(
targetPaymentCodeString: targetPaymentCodeString,
diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart
index 0d031b8..73ba39d 100644
--- a/test/paynym_p2tr_test.dart
+++ b/test/paynym_p2tr_test.dart
@@ -3,8 +3,11 @@ import 'package:bip39/bip39.dart' as bip39;
import 'package:bip47/bip47.dart';
import 'package:bitcoindart/bitcoindart.dart' as bitcoindart;
import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/exceptions/wallet/paynym_send_exception.dart';
+import 'package:stackwallet/models/input.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart';
import 'package:stackwallet/models/paynym/paynym_account_lite.dart';
+import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
UTXO _utxo(String txid, int blockTime, String address) => UTXO(
@@ -77,6 +80,32 @@ void main() {
]);
});
+ test('notification requires a non-Taproot designated input', () {
+ final segwit = StandardInput(
+ _utxo('segwit', 1, 'bc1qsegwit'),
+ derivePathType: DerivePathType.bip84,
+ );
+ final taproot = StandardInput(
+ _utxo('taproot', 1, 'bc1ptaproot'),
+ derivePathType: DerivePathType.bip86,
+ );
+
+ expect(
+ () => validatePaynymNotificationInputs([taproot]),
+ throwsA(
+ isA<PaynymSendException>().having(
+ (error) => error.message,
+ 'message',
+ contains('non-Taproot UTXO'),
+ ),
+ ),
+ );
+ expect(
+ () => validatePaynymNotificationInputs([segwit, taproot]),
+ returnsNormally,
+ );
+ });
+
group('PaynymAccountLite taproot inference', () {
test('inferTaproot returns true for taproot-enabled payment code', () {
final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString);
Why this scored 42/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.