fix(paynym): handle taproot inputs in notification tx parsing/building
What changed, and why it matters
This commit fixes how Stack Wallet handles Bitcoin PayNym notification transactions when the wallet has taproot-style coins. PayNym notification transactions need the sender's raw public key to set up a private payment channel. Taproot inputs hide that raw public key, so the previous code could crash or fail to parse/build notifications when taproot coins were selected. The fix avoids picking taproot coins for notification transactions and gracefully returns null instead of crashing when a taproot input is encountered during parsing.
Review whether returning null silently in notification parsing could cause higher-level code to ignore a valid but taproot-funded notification, and ensure users are warned if their wallet has only taproot UTXOs when creating a PayNym notification. Consider adding tests covering taproot-only and mixed UTXO scenarios.
Security signals we found
Null-dereference/crash avoided by removing non-null assertion on public key extraction
Cryptographic protocol limitation addressed: taproot inputs do not reveal raw public keys needed for BIP47 ECDH
UTXO selection changed to prefer non-taproot outputs for notification transactions
Graceful failure added when taproot inputs are used in notification parsing/building
Evidence from the diff
The patch modifies paynym_interface.dart in two places. First, when selecting UTXOs for a BIP47 notification transaction, it now sorts taproot outputs (addresses starting with bc1p or tb1p) to the end so non-taproot outputs are preferred. Second, in two notification parsing/building helpers it removes the non-null assertion on _pubKeyFromInput and returns null when the public key cannot be extracted, because taproot inputs do not expose the raw public key required for ECDH with the receiver’s notification key.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dartBIP47/PayNym notification transaction constructionBIP47/PayNym notification transaction parsingInspect captured patch +25 / −4
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
index bb69584..c184033 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<T extends PaynymCurrencyInterface>
);
}
- // 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<T extends PaynymCurrencyInterface>
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<T extends PaynymCurrencyInterface>
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!;
Why this scored 44/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.