Shuffle change output on hardware wallet and Bitcoin Cash sends (#3432)

* Shuffle change output on hardware wallet sends Hardware wallet BTC/LTC sends passed outputs to the PSBT/device in the given order (change last), a position fingerprint. The outputOrdering param was plumbed but ignored. Add orderOutputs() and apply it in both buildHardwareWalletTransaction paths; set the send call site to shuffle. Refs #3376 (the software send + RBF paths were covered by #3420). * Shuffle change output on Bitcoin Cash sends The BCH send path built via ForkedTransactionBuilder with outputOrdering: none, leaving change deterministically last. The builder shuffles natively and change is found by isChange, not position, so flip it to shuffle (matches the BTC software path from #3420). Refs #3376. * Update cw_bitcoin/lib/electrum_wallet.dart Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Cindy committed Jul 25, 2026 at 21:30 UTC b73c46ac59af0f3902be0f94b771a98045698531
5 files changed +91 -5
cw_bitcoin/lib/bitcoin_wallet.dart
+6 -2
@@ -17,6 +17,7 @@ import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
17 import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
18 import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
19 import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart';
20 +import 'package:cw_bitcoin/output_ordering.dart';
21 import 'package:cw_bitcoin/payjoin/manager.dart';
22 import 'package:cw_bitcoin/payjoin/storage.dart';
23 import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
@@ -467,8 +468,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
468 final masterFingerprint =
469 await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
470
471 + final orderedOutputs = orderOutputs(outputs, outputOrdering);
472 +
473 final psbt = await buildPsbt(
471 - outputs: outputs,
474 + outputs: orderedOutputs,
475 fee: fee,
476 network: network,
477 utxos: utxos,
@@ -478,7 +481,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
481 memo: memo,
482 enableRBF: enableRBF,
483 inputOrdering: inputOrdering,
481 - outputOrdering: outputOrdering,
484 + // Already applied above; don't reorder again.
485 + outputOrdering: BitcoinOrdering.none,
486 );
487
488 final psbtStr = base64Encode(psbt.serialize());
cw_bitcoin/lib/electrum_wallet.dart
+7 -2
@@ -1485,7 +1485,9 @@ abstract class ElectrumWalletBase
1485 fee: estimatedTx.fee.amount,
1486 network: network,
1487 memo: estimatedTx.memo,
1488 - outputOrdering: BitcoinOrdering.none,
1488 + // Shuffle so the change output isn't placed deterministically last
1489 + // (privacy fingerprint). Applied by orderOutputs in the builder.
1490 + outputOrdering: BitcoinOrdering.shuffle,
1491 enableRBF: true,
1492 cwOutputs: transactionCredentials.outputs,
1493 );
@@ -1518,7 +1520,10 @@ abstract class ElectrumWalletBase
1520 fee: estimatedTx.fee.amount,
1521 network: network,
1522 memo: estimatedTx.memo,
1521 - outputOrdering: BitcoinOrdering.none,
1523 + // Shuffle so the change output isn't placed deterministically last
1524 + // (privacy fingerprint). Change is found by isChange, not position.
1525 + inputOrdering: BitcoinOrdering.shuffle,
1526 + outputOrdering: BitcoinOrdering.shuffle,
1527 enableRBF: !estimatedTx.spendsUnconfirmedTX,
1528 );
1529 } else {
cw_bitcoin/lib/litecoin_wallet.dart
+5 -1
@@ -30,6 +30,7 @@ import 'package:cw_bitcoin/electrum_wallet.dart';
30 import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
31 import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
32 import 'package:cw_bitcoin/litecoin_wallet_addresses.dart';
33 +import 'package:cw_bitcoin/output_ordering.dart';
34 import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
35 import 'package:cw_bitcoin/psbt/transaction_builder.dart';
36 import 'package:cw_bitcoin/utils.dart';
@@ -1584,8 +1585,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
1585 ));
1586 }
1587
1588 + final orderedOutputs = orderOutputs(outputs, outputOrdering);
1589 +
1590 final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService)
1588 - .signLitecoinTransaction(outputs: outputs, inputs: readyInputs, publicKeys: publicKeys);
1591 + .signLitecoinTransaction(
1592 + outputs: orderedOutputs, inputs: readyInputs, publicKeys: publicKeys);
1593
1594 return BtcTransaction.fromRaw(rawHex);
1595 }
cw_bitcoin/lib/output_ordering.dart new
+21
@@ -0,0 +1,21 @@
1 +import 'dart:math';
2 +
3 +import 'package:bitcoin_base/bitcoin_base.dart';
4 +
5 +/// Applies [ordering] to a transaction's [outputs] before building.
6 +///
7 +/// Returns a new list; the input is never mutated. Only [BitcoinOrdering.shuffle]
8 +/// reorders (using a secure RNG by default) so the change output is no longer
9 +/// placed deterministically last. Any other value preserves the given order,
10 +/// matching the pre-existing hardware-wallet behavior.
11 +List<T> orderOutputs<T>(
12 + List<T> outputs,
13 + BitcoinOrdering ordering, {
14 + Random? rng,
15 +}) {
16 + final result = List<T>.of(outputs);
17 + if (ordering == BitcoinOrdering.shuffle) {
18 + result.shuffle(rng ?? Random.secure());
19 + }
20 + return result;
21 +}
cw_bitcoin/test/output_ordering_test.dart new
+52
@@ -0,0 +1,52 @@
1 +import 'dart:math';
2 +
3 +import 'package:bitcoin_base/bitcoin_base.dart';
4 +import 'package:cw_bitcoin/output_ordering.dart';
5 +import 'package:flutter_test/flutter_test.dart';
6 +
7 +void main() {
8 + group('orderOutputs', () {
9 + test('none preserves the given order', () {
10 + final outputs = [0, 1, 2, 3, 4];
11 + expect(orderOutputs(outputs, BitcoinOrdering.none), outputs);
12 + });
13 +
14 + test('does not mutate the input list', () {
15 + final outputs = [0, 1, 2, 3, 4];
16 + orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(1));
17 + expect(outputs, [0, 1, 2, 3, 4]);
18 + });
19 +
20 + test('returns a new list instance', () {
21 + final outputs = [0, 1, 2];
22 + expect(identical(orderOutputs(outputs, BitcoinOrdering.none), outputs), isFalse);
23 + });
24 +
25 + test('shuffle preserves the multiset of outputs', () {
26 + final outputs = [0, 1, 2, 3, 4, 5, 6, 7];
27 + final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(7));
28 + expect(shuffled.length, outputs.length);
29 + expect(shuffled.toSet(), outputs.toSet());
30 + });
31 +
32 + test('shuffle is deterministic for a given seed', () {
33 + final outputs = [0, 1, 2, 3, 4, 5];
34 + final a = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
35 + final b = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
36 + expect(a, b);
37 + });
38 +
39 + test('shuffle does not keep the change output deterministically last', () {
40 + // Last element (99) models the change output, appended last today.
41 + final outputs = [0, 1, 2, 3, 99];
42 + final changePositions = <int>{};
43 + for (var seed = 0; seed < 50; seed++) {
44 + final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(seed));
45 + changePositions.add(shuffled.indexOf(99));
46 + }
47 + // Across seeds the change lands in more than one position, and not always last.
48 + expect(changePositions.length, greaterThan(1));
49 + expect(changePositions, isNot(equals({outputs.length - 1})));
50 + });
51 + });
52 +}