Shuffle change output on hardware wallet and Bitcoin Cash sends (#3432)
What changed, and why it matters
This commit fixes a privacy weakness in Cake Wallet's Bitcoin, Litecoin, and Bitcoin Cash sending flows. Previously, when using a hardware wallet or sending Bitcoin Cash, the app's own 'change' output was always placed last in the transaction. That predictable ordering acts like a fingerprint, making it easier for outside observers to identify which output belongs to the sender and trace the user's funds. The patch shuffles output order so the change output no longer sits in a fixed, telltale position.
No immediate user action required; this is a privacy-hardening fix. Users on affected versions who made recurring hardware-wallet BTC/LTC or BCH transactions may have leaked change-output position; updating to the patched version removes the fingerprint going forward. Reviewers should confirm that all other coin send paths (e.g., software BTC/LTC, PayJoin, Lightning) already use shuffle or otherwise hide change position, and that the hardware signer UI still displays outputs correctly after shuffling.
Security signals we found
privacy fingerprint: change output placed deterministically last
output ordering parameter was plumbed but ignored on hardware wallet paths
Bitcoin Cash send path used BitcoinOrdering.none, leaving change last
patch adds deterministic shuffle helper with secure default RNG
unit test explicitly models change output and verifies non-deterministic position
cross-references issue #3376 and prior PR #3420
Evidence from the diff
The change introduces an orderOutputs() helper in cw_bitcoin/lib/output_ordering.dart that applies BitcoinOrdering.shuffle using a secure RNG, returning a new list without mutating the input. It wires this helper into BitcoinWalletBase.buildHardwareWalletTransaction and LitecoinWalletBase.signLitecoinTransaction before passing outputs to the PSBT/hardware signer, and sets outputOrdering to BitcoinOrdering.shuffle for Bitcoin Cash sends in electrum_wallet.dart. A unit test verifies that shuffle preserves the multiset, does not mutate inputs, and does not leave the change output deterministically last. This closes the hardware-wallet and BCH gaps referenced in #3376; the software BTC send and RBF paths were already addressed in #3420.
Changed components
cw_bitcoin/lib/output_ordering.dart (new)cw_bitcoin/lib/bitcoin_wallet.dartcw_bitcoin/lib/litecoin_wallet.dartcw_bitcoin/lib/electrum_wallet.dartcw_bitcoin/test/output_ordering_test.dartInspect captured patch +91 / −5
diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart
index 7e76132..b8e53d1 100644
--- a/cw_bitcoin/lib/bitcoin_wallet.dart
+++ b/cw_bitcoin/lib/bitcoin_wallet.dart
@@ -17,6 +17,7 @@ import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart';
+import 'package:cw_bitcoin/output_ordering.dart';
import 'package:cw_bitcoin/payjoin/manager.dart';
import 'package:cw_bitcoin/payjoin/storage.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
@@ -467,8 +468,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
final masterFingerprint =
await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
+ final orderedOutputs = orderOutputs(outputs, outputOrdering);
+
final psbt = await buildPsbt(
- outputs: outputs,
+ outputs: orderedOutputs,
fee: fee,
network: network,
utxos: utxos,
@@ -478,7 +481,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
memo: memo,
enableRBF: enableRBF,
inputOrdering: inputOrdering,
- outputOrdering: outputOrdering,
+ // Already applied above; don't reorder again.
+ outputOrdering: BitcoinOrdering.none,
);
final psbtStr = base64Encode(psbt.serialize());
diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart
index 6396abe..ceba0ec 100644
--- a/cw_bitcoin/lib/electrum_wallet.dart
+++ b/cw_bitcoin/lib/electrum_wallet.dart
@@ -1485,7 +1485,9 @@ abstract class ElectrumWalletBase
fee: estimatedTx.fee.amount,
network: network,
memo: estimatedTx.memo,
- outputOrdering: BitcoinOrdering.none,
+ // Shuffle so the change output isn't placed deterministically last
+ // (privacy fingerprint). Applied by orderOutputs in the builder.
+ outputOrdering: BitcoinOrdering.shuffle,
enableRBF: true,
cwOutputs: transactionCredentials.outputs,
);
@@ -1518,7 +1520,10 @@ abstract class ElectrumWalletBase
fee: estimatedTx.fee.amount,
network: network,
memo: estimatedTx.memo,
- outputOrdering: BitcoinOrdering.none,
+ // Shuffle so the change output isn't placed deterministically last
+ // (privacy fingerprint). Change is found by isChange, not position.
+ inputOrdering: BitcoinOrdering.shuffle,
+ outputOrdering: BitcoinOrdering.shuffle,
enableRBF: !estimatedTx.spendsUnconfirmedTX,
);
} else {
diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart
index 2395937..aa8fcd3 100644
--- a/cw_bitcoin/lib/litecoin_wallet.dart
+++ b/cw_bitcoin/lib/litecoin_wallet.dart
@@ -30,6 +30,7 @@ import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/litecoin_wallet_addresses.dart';
+import 'package:cw_bitcoin/output_ordering.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
@@ -1584,8 +1585,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
));
}
+ final orderedOutputs = orderOutputs(outputs, outputOrdering);
+
final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService)
- .signLitecoinTransaction(outputs: outputs, inputs: readyInputs, publicKeys: publicKeys);
+ .signLitecoinTransaction(
+ outputs: orderedOutputs, inputs: readyInputs, publicKeys: publicKeys);
return BtcTransaction.fromRaw(rawHex);
}
diff --git a/cw_bitcoin/lib/output_ordering.dart b/cw_bitcoin/lib/output_ordering.dart
new file mode 100644
index 0000000..36a980e
--- /dev/null
+++ b/cw_bitcoin/lib/output_ordering.dart
@@ -0,0 +1,21 @@
+import 'dart:math';
+
+import 'package:bitcoin_base/bitcoin_base.dart';
+
+/// Applies [ordering] to a transaction's [outputs] before building.
+///
+/// Returns a new list; the input is never mutated. Only [BitcoinOrdering.shuffle]
+/// reorders (using a secure RNG by default) so the change output is no longer
+/// placed deterministically last. Any other value preserves the given order,
+/// matching the pre-existing hardware-wallet behavior.
+List<T> orderOutputs<T>(
+ List<T> outputs,
+ BitcoinOrdering ordering, {
+ Random? rng,
+}) {
+ final result = List<T>.of(outputs);
+ if (ordering == BitcoinOrdering.shuffle) {
+ result.shuffle(rng ?? Random.secure());
+ }
+ return result;
+}
diff --git a/cw_bitcoin/test/output_ordering_test.dart b/cw_bitcoin/test/output_ordering_test.dart
new file mode 100644
index 0000000..d0fc7de
--- /dev/null
+++ b/cw_bitcoin/test/output_ordering_test.dart
@@ -0,0 +1,52 @@
+import 'dart:math';
+
+import 'package:bitcoin_base/bitcoin_base.dart';
+import 'package:cw_bitcoin/output_ordering.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ group('orderOutputs', () {
+ test('none preserves the given order', () {
+ final outputs = [0, 1, 2, 3, 4];
+ expect(orderOutputs(outputs, BitcoinOrdering.none), outputs);
+ });
+
+ test('does not mutate the input list', () {
+ final outputs = [0, 1, 2, 3, 4];
+ orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(1));
+ expect(outputs, [0, 1, 2, 3, 4]);
+ });
+
+ test('returns a new list instance', () {
+ final outputs = [0, 1, 2];
+ expect(identical(orderOutputs(outputs, BitcoinOrdering.none), outputs), isFalse);
+ });
+
+ test('shuffle preserves the multiset of outputs', () {
+ final outputs = [0, 1, 2, 3, 4, 5, 6, 7];
+ final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(7));
+ expect(shuffled.length, outputs.length);
+ expect(shuffled.toSet(), outputs.toSet());
+ });
+
+ test('shuffle is deterministic for a given seed', () {
+ final outputs = [0, 1, 2, 3, 4, 5];
+ final a = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
+ final b = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
+ expect(a, b);
+ });
+
+ test('shuffle does not keep the change output deterministically last', () {
+ // Last element (99) models the change output, appended last today.
+ final outputs = [0, 1, 2, 3, 99];
+ final changePositions = <int>{};
+ for (var seed = 0; seed < 50; seed++) {
+ final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(seed));
+ changePositions.add(shuffled.indexOf(99));
+ }
+ // Across seeds the change lands in more than one position, and not always last.
+ expect(changePositions.length, greaterThan(1));
+ expect(changePositions, isNot(equals({outputs.length - 1})));
+ });
+ });
+}
Why this scored 49/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.