What changed, and why it matters
This commit fixes a sorting bug in how the wallet chooses which coins (UTXOs) to use when sending a BIP47/Paynym notification transaction. The code's comment said taproot coins should be used last and the oldest non-taproot coins first, but the actual sort was doing the opposite for age (newest first). The fix makes the code match the comment. Because notification transactions are special (they reveal a payment code to the recipient), using the wrong coin order could cause the wallet to select a taproot coin when a non-taproot one was available, potentially breaking notification parsing for the receiver or causing the wallet to fall back to a less desirable coin selection.
Review whether the previous incorrect sorting caused any failed or misconstructed BIP47/Paynym notification transactions in production, especially when taproot UTXOs were present. Consider adding integration tests for notification transaction coin selection. No immediate emergency patch is indicated, but the fix should be included in the next release.
Security signals we found
Fixes implementation/comment mismatch in coin selection for BIP47 notification transactions
Taproot UTXOs deprioritized because taproot inputs do not expose the raw public key needed for ECDH in BIP47 notification parsing
Incorrect age sort could cause newer UTXOs to be selected before older ones, contrary to intended wallet behavior
No explicit security advisory, CVE, or vendor security disclosure supplied
Evidence from the diff
The change extracts UTXO sorting for Paynym notification transactions into a testable helper, comparePaynymNotificationUtxos, and corrects the age comparison from b.blockTime!.compareTo(a.blockTime!) (descending/newest first) to a.blockTime!.compareTo(b.blockTime!) (ascending/oldest first). Taproot UTXOs (addresses starting with bc1p or tb1p) are still pushed to the end. A unit test verifies the intended order: non-taproot oldest first, then non-taproot newer, then taproot oldest, then taproot newest. The change also swaps package:test for package:flutter_test and adds package:meta for @visibleForTesting.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.darttest/paynym_p2tr_test.dartInspect captured patch +52 / −15
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
index 6c815ec..3789555 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
@@ -9,6 +9,7 @@ import 'package:bitcoindart/src/utils/constants/op.dart' as op;
import 'package:bitcoindart/src/utils/script.dart' as bscript;
import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib;
import 'package:isar_community/isar.dart';
+import 'package:meta/meta.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:tuple/tuple.dart';
@@ -24,7 +25,6 @@ import '../../../utilities/bip32_utils.dart';
import '../../../utilities/bip47_utils.dart';
import '../../../utilities/enums/derive_path_type_enum.dart';
import '../../../utilities/extensions/extensions.dart';
-import '../../../utilities/format.dart';
import '../../../utilities/logger.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../../crypto_currency/interfaces/paynym_currency_interface.dart';
@@ -46,6 +46,20 @@ String _receivingPaynymAddressDerivationPath(
String _sendPaynymAddressDerivationPath(int index, {required bool testnet}) =>
"${_basePaynymDerivePath(testnet: testnet)}/0/$index";
+@visibleForTesting
+int comparePaynymNotificationUtxos(UTXO a, UTXO 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 a.blockTime!.compareTo(b.blockTime!);
+}
+
mixin PaynymInterface<T extends PaynymCurrencyInterface>
on Bip39HDWallet<T>, ElectrumXInterface<T> {
btc_dart.NetworkType get networkType => btc_dart.NetworkType(
@@ -570,18 +584,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
// 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!);
- });
+ spendableOutputs.sort(comparePaynymNotificationUtxos);
BigInt satoshisBeingUsed = BigInt.zero;
int outputsBeingUsed = 0;
diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart
index e4fd1ab..0d031b8 100644
--- a/test/paynym_p2tr_test.dart
+++ b/test/paynym_p2tr_test.dart
@@ -2,12 +2,30 @@ 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:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart';
import 'package:stackwallet/models/paynym/paynym_account_lite.dart';
-import 'package:test/test.dart';
+import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
+
+UTXO _utxo(String txid, int blockTime, String address) => UTXO(
+ walletId: 'wallet',
+ txid: txid,
+ vout: 0,
+ value: 1,
+ name: '',
+ isBlocked: false,
+ blockedReason: null,
+ isCoinbase: false,
+ blockHash: 'hash',
+ blockHeight: 1,
+ blockTime: blockTime,
+ address: address,
+);
void main() {
const mnemonic =
- 'response seminar brave million suit skate inhale proud weapon daring champion';
+ 'response seminar brave million suit skate inhale proud weapon '
+ 'daring champion';
final networkType = bip32.NetworkType(
wif: bitcoindart.bitcoin.wif,
@@ -43,6 +61,22 @@ void main() {
taprootPaymentCodeString = taprootCode.toString();
});
+ test('notification UTXOs prefer non-Taproot then oldest', () {
+ final utxos = [
+ _utxo('taproot-newer', 50, 'bc1ptaproot'),
+ _utxo('legacy-newer', 200, 'bc1qlegacy'),
+ _utxo('taproot-older', 25, 'tb1ptaproot'),
+ _utxo('legacy-older', 100, '1legacy'),
+ ]..sort(comparePaynymNotificationUtxos);
+
+ expect(utxos.map((utxo) => utxo.txid).toList(), [
+ 'legacy-older',
+ 'legacy-newer',
+ 'taproot-older',
+ 'taproot-newer',
+ ]);
+ });
+
group('PaynymAccountLite taproot inference', () {
test('inferTaproot returns true for taproot-enabled payment code', () {
final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString);
Why this scored 34/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.