diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index c6082d4e0..269619cf3 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -176,16 +176,18 @@ jobs: - name: Rename apk file run: | + sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') cd build/app/outputs/flutter-apk mkdir test-apk - cp app-arm64-v8a-release.apk test-apk/${BRANCH_NAME}.apk - cp app-x86_64-release.apk test-apk/${BRANCH_NAME}_x86.apk + cp app-arm64-v8a-release.apk test-apk/${sanitized_branch_name}.apk + cp app-x86_64-release.apk test-apk/${sanitized_branch_name}_x86.apk - name: Find APK file id: find_apk run: | set -x - apk_file=$(ls build/app/outputs/flutter-apk/test-apk/${BRANCH_NAME}.apk || exit 1) + sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') + apk_file=$(ls build/app/outputs/flutter-apk/test-apk/${sanitized_branch_name}.apk || exit 1) echo "APK_FILE=$apk_file" >> $GITHUB_ENV - name: Write BRANCH_NAME to env run: echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 7e76132a8..b8e53d11b 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 641feb673..ceba0eccd 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 { @@ -2507,10 +2512,16 @@ abstract class ElectrumWalletBase Map historiesWithDetails, BitcoinAddressType type, ) async { - final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); - final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); - walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + + final addressesByType = + walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); + + final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); + final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); + final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); + final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); + + walletAddresses.hiddenAddresses.addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); await walletAddresses.saveAddressesInBox(); await Future.wait(addressesByType.map((addressRecord) async { final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); @@ -2519,7 +2530,9 @@ abstract class ElectrumWalletBase addressRecord.txCount = history.length; historiesWithDetails.addAll(history); - final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + final matchedAddresses = addressRecord.isHidden + ? (addressRecord.isLegacyDerivation ? changeLegacy : changeStandard) + : (addressRecord.isLegacyDerivation ? receiveLegacy : receiveStandard); final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden @@ -2623,18 +2636,19 @@ abstract class ElectrumWalletBase Future fetchTransactionsForAddressTypeBatch( Map historiesWithDetails, BitcoinAddressType type) async { - final addressesByType = - walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); - final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList(); - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList(); - walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); + final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); + final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); + final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); + + walletAddresses.hiddenAddresses.addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); await walletAddresses.saveAddressesInBox(); await fetchTransactionsForAddressesBranchBatch( historiesWithDetails, type, - receiveAddresses, + receiveStandard, isHidden: false, isLegacyDerivation: false, ); @@ -2642,7 +2656,7 @@ abstract class ElectrumWalletBase await fetchTransactionsForAddressesBranchBatch( historiesWithDetails, type, - hiddenAddresses, + changeStandard, isHidden: true, isLegacyDerivation: false, ); @@ -2650,7 +2664,7 @@ abstract class ElectrumWalletBase await fetchTransactionsForAddressesBranchBatch( historiesWithDetails, type, - receiveAddresses, + receiveLegacy, isHidden: false, isLegacyDerivation: true, ); @@ -2658,7 +2672,7 @@ abstract class ElectrumWalletBase await fetchTransactionsForAddressesBranchBatch( historiesWithDetails, type, - hiddenAddresses, + changeLegacy, isHidden: true, isLegacyDerivation: true, ); @@ -2734,6 +2748,11 @@ abstract class ElectrumWalletBase } } + List getAddressBranchByType({required bool hidden, required bool legacy, required BitcoinAddressType + type}) => walletAddresses.allAddresses.where((addr) => addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + int _highestUsedIndex(List addresses) { for (int i = addresses.length - 1; i >= 0; i--) { if (addresses[i].isUsed) return i; diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 23959374c..aa8fcd330 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 000000000..36a980e93 --- /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 orderOutputs( + List outputs, + BitcoinOrdering ordering, { + Random? rng, +}) { + final result = List.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 000000000..d0fc7de05 --- /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 = {}; + 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}))); + }); + }); +} diff --git a/lib/core/address_resolver/yat/yat_address_provider.dart b/lib/core/address_resolver/yat/yat_address_provider.dart index d8ee3454b..73363fa1a 100644 --- a/lib/core/address_resolver/yat/yat_address_provider.dart +++ b/lib/core/address_resolver/yat/yat_address_provider.dart @@ -20,7 +20,7 @@ class YatAddressProvider extends AddressLookupProvider { bool canHandle(String q) => q.hasOnlyEmojis; // Yat handle example: 🐢🐾 @override - bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsYatService; @override Future> resolve({ diff --git a/lib/core/address_resolver/zcash/zcash_me_address_provider.dart b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart index 6dd0968d9..3836ee46f 100644 --- a/lib/core/address_resolver/zcash/zcash_me_address_provider.dart +++ b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart @@ -15,7 +15,7 @@ class ZcashMeAddressProvider extends AddressLookupProvider { List get supportedCurrencies => [CryptoCurrency.zec]; @override - bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashAddress; @override bool canHandle(String query) { diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index ef8a213c8..c4011172c 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -628,6 +628,13 @@ Future defaultSettingsMigration( oldUri: ['nodes.hashvault.pro:18081'], ); break; + case 69: + _changeExchangeProviderAvailability( + sharedPreferences, + providerName: "Exolix", + enabled: false, + ); + break; default: break; } diff --git a/lib/exchange/provider/changenow_exchange_provider.dart b/lib/exchange/provider/changenow_exchange_provider.dart index 15913b29e..e6c94d559 100644 --- a/lib/exchange/provider/changenow_exchange_provider.dart +++ b/lib/exchange/provider/changenow_exchange_provider.dart @@ -325,6 +325,8 @@ class ChangeNowExchangeProvider extends ExchangeProvider { return 'btc'; case CryptoCurrency.arb: return 'arbitrum'; + case CryptoCurrency.nano: + return 'nano'; default: return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title.toLowerCase(); } @@ -337,6 +339,8 @@ class ChangeNowExchangeProvider extends ExchangeProvider { switch (currency) { case CryptoCurrency.zec: return 'zec'; + case CryptoCurrency.maticpoly: + return 'matic'; default: return currency.title.toLowerCase(); } @@ -344,7 +348,7 @@ class ChangeNowExchangeProvider extends ExchangeProvider { String _normalizeTag(String tag) { switch (tag) { - case 'POLY': + case 'POL': return 'matic'; case 'LN': return 'lightning'; diff --git a/lib/main.dart b/lib/main.dart index f292731f8..923ace77d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -315,7 +315,7 @@ Future initializeAppConfigs({bool loadWallet = true}) async { payjoinSessionSource: payjoinSessionSource, anonpayInvoiceInfo: anonpayInvoiceInfo, havenSeedStore: havenSeedStore, - initialMigrationVersion: 68, + initialMigrationVersion: 69, ); } diff --git a/lib/view_model/support_view_model.dart b/lib/view_model/support_view_model.dart index 96cf66fb9..4f1120158 100644 --- a/lib/view_model/support_view_model.dart +++ b/lib/view_model/support_view_model.dart @@ -44,7 +44,7 @@ abstract class SupportViewModelBase with Store { LinkListItem( title: 'Telegram', icon: 'assets/images/Telegram.png', - linkTitle: 't.me/cakewallet', + linkTitle: 't.me/cakewalletannouncements', link: 'https://t.me/cakewalletannouncements'), LinkListItem( title: 'Telegram Support Bot', @@ -75,7 +75,7 @@ abstract class SupportViewModelBase with Store { title: 'SwapTrade', icon: 'assets/images/swap_trade.png', linkTitle: 'help.swaptrade.io', - link: 'mailto:support@exolix.com'), + link: 'https://help.swaptrade.io/contact/'), LinkListItem( title: 'Trocador', icon: 'assets/images/trocador.png', diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 7a5bbfc29..f1ab85bee 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -947,7 +947,7 @@ "seed_alert_yes": "Yes, I have", "seed_and_keys": "Seed & Keys", "seed_choose": "Choose seed language", - "seed_display_path": "Menu β†’ Security and Backup β†’ Show key/seeds", + "seed_display_path": "Settings β†’ Wallet settings β†’ Seed & Keys", "seed_hex_form": "Wallet seed (hex form)", "seed_key": "Seed key", "seed_language": "Seed language",