| 1 | import 'dart:async'; |
| 2 | import 'dart:convert'; |
| 3 | import 'dart:isolate'; |
| 4 | import 'dart:math' show Random; |
| 5 | |
| 6 | import 'package:bitcoin_base/bitcoin_base.dart'; |
| 7 | import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; |
| 8 | import 'package:cw_bitcoin/locktime.dart'; |
| 9 | import 'package:cw_core/hardware/hardware_wallet_service.dart'; |
| 10 | import 'package:cw_core/root_dir.dart'; |
| 11 | import 'package:cw_core/utils/proxy_wrapper.dart'; |
| 12 | import 'package:cw_core/utils/print_verbose.dart'; |
| 13 | import 'package:cw_bitcoin/bitcoin_wallet.dart'; |
| 14 | import 'package:cw_bitcoin/coin_selection.dart'; |
| 15 | import 'package:cw_bitcoin/litecoin_wallet.dart'; |
| 16 | import 'package:shared_preferences/shared_preferences.dart'; |
| 17 | import 'package:blockchain_utils/blockchain_utils.dart'; |
| 18 | import 'package:collection/collection.dart'; |
| 19 | import 'package:cw_bitcoin/address_from_output.dart'; |
| 20 | import 'package:cw_bitcoin/bitcoin_address_record.dart'; |
| 21 | import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; |
| 22 | import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; |
| 23 | import 'package:cw_bitcoin/bitcoin_unspent.dart'; |
| 24 | import 'package:cw_bitcoin/bitcoin_wallet_keys.dart'; |
| 25 | import 'package:cw_bitcoin/electrum.dart' as electrum; |
| 26 | import 'package:cw_bitcoin/electrum_balance.dart'; |
| 27 | import 'package:cw_bitcoin/electrum_derivations.dart'; |
| 28 | import 'package:cw_bitcoin/electrum_transaction_history.dart'; |
| 29 | import 'package:cw_bitcoin/electrum_transaction_info.dart'; |
| 30 | import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; |
| 31 | import 'package:cw_bitcoin/exceptions.dart'; |
| 32 | import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; |
| 33 | import 'package:cw_bitcoin/utils.dart'; |
| 34 | import 'package:cw_core/amount/money.dart'; |
| 35 | import 'package:cw_core/crypto_currency.dart'; |
| 36 | import 'package:cw_core/encryption_file_utils.dart'; |
| 37 | import 'package:cw_core/get_height_by_date.dart'; |
| 38 | import 'package:cw_core/node.dart'; |
| 39 | import 'package:cw_core/output_info.dart'; |
| 40 | import 'package:cw_core/pending_transaction.dart'; |
| 41 | import 'package:cw_core/sync_status.dart'; |
| 42 | import 'package:cw_core/transaction_direction.dart'; |
| 43 | import 'package:cw_core/transaction_priority.dart'; |
| 44 | import 'package:cw_core/unspent_coin_type.dart'; |
| 45 | import 'package:cw_core/unspent_coins_info.dart'; |
| 46 | import 'package:cw_core/utils/socket_health_logger.dart'; |
| 47 | import 'package:cw_core/utils/tor/abstract.dart'; |
| 48 | import 'package:cw_core/wallet_base.dart'; |
| 49 | import 'package:cw_core/wallet_info.dart'; |
| 50 | import 'package:cw_core/wallet_keys_file.dart'; |
| 51 | import 'package:cw_core/wallet_type.dart'; |
| 52 | import 'package:flutter/foundation.dart'; |
| 53 | import 'package:hex/hex.dart'; |
| 54 | import 'package:hive/hive.dart'; |
| 55 | import 'package:mobx/mobx.dart'; |
| 56 | import 'package:rxdart/subjects.dart'; |
| 57 | import 'package:sp_scanner/sp_scanner.dart'; |
| 58 | |
| 59 | part 'electrum_wallet.g.dart'; |
| 60 | |
| 61 | class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; |
| 62 | |
| 63 | abstract class ElectrumWalletBase |
| 64 | extends WalletBase<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo> |
| 65 | with Store, WalletKeysFile { |
| 66 | ElectrumWalletBase({ |
| 67 | required String password, |
| 68 | required WalletInfo walletInfo, |
| 69 | required DerivationInfo derivationInfo, |
| 70 | required Box<UnspentCoinsInfo> unspentCoinsInfo, |
| 71 | required this.network, |
| 72 | required this.encryptionFileUtils, |
| 73 | String? xpub, |
| 74 | String? mnemonic, |
| 75 | Uint8List? seedBytes, |
| 76 | this.passphrase, |
| 77 | List<BitcoinAddressRecord>? initialAddresses, |
| 78 | electrum.ElectrumClient? electrumClient, |
| 79 | ElectrumBalance? initialBalance, |
| 80 | CryptoCurrency? currency, |
| 81 | bool? alwaysScan, |
| 82 | bool useLightning = true, |
| 83 | }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType), |
| 84 | accountHD = getAccountHDWallet( |
| 85 | currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType), |
| 86 | syncStatus = NotConnectedSyncStatus(), |
| 87 | _password = password, |
| 88 | _feeRates = <int>[], |
| 89 | _isTransactionUpdating = false, |
| 90 | isEnabledAutoGenerateSubaddress = true, |
| 91 | unspentCoins = [], |
| 92 | _scripthashesUpdateSubject = {}, |
| 93 | this.alwaysScan = alwaysScan, |
| 94 | silentPaymentsScanningActive = alwaysScan ?? false, |
| 95 | balance = ObservableMap<CryptoCurrency, ElectrumBalance>.of(currency != null |
| 96 | ? { |
| 97 | currency: initialBalance ?? |
| 98 | ElectrumBalance( |
| 99 | confirmed: Money.zero(currency), |
| 100 | unconfirmed: Money.zero(currency), |
| 101 | frozen: Money.zero(currency), |
| 102 | ) |
| 103 | } |
| 104 | : {}), |
| 105 | this.unspentCoinsInfo = unspentCoinsInfo, |
| 106 | this.isTestnet = !network.isMainnet, |
| 107 | this._mnemonic = mnemonic, |
| 108 | _useLightning = useLightning, |
| 109 | super(walletInfo, derivationInfo) { |
| 110 | this.electrumClient = electrumClient ?? electrum.ElectrumClient(); |
| 111 | this.walletInfo = walletInfo; |
| 112 | this.derivationInfo = derivationInfo; |
| 113 | transactionHistory = ElectrumTransactionHistory( |
| 114 | walletInfo: walletInfo, |
| 115 | password: password, |
| 116 | encryptionFileUtils: encryptionFileUtils, |
| 117 | ); |
| 118 | |
| 119 | reaction((_) => syncStatus, _syncStatusReaction); |
| 120 | |
| 121 | sharedPrefs.complete(SharedPreferences.getInstance()); |
| 122 | |
| 123 | final supportedTypes = supportedAddressTypes(walletInfo.type); |
| 124 | mainHdByType = <BitcoinAddressType, Bip32Slip10Secp256k1>{}; |
| 125 | sideHdByType = <BitcoinAddressType, Bip32Slip10Secp256k1>{}; |
| 126 | |
| 127 | final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; |
| 128 | |
| 129 | final canDeriveFromSeed = _masterHD != null && currency != null; |
| 130 | |
| 131 | if (isElectrumDerivation) { |
| 132 | // Electrum derivation does not follow BIP44/49/84 etc. standards |
| 133 | for (final type in supportedTypes) { |
| 134 | mainHdByType[type] = mainHd; // accountHD.child(0) |
| 135 | sideHdByType[type] = sideHd; // accountHD.child(1) |
| 136 | } |
| 137 | } else if (canDeriveFromSeed) { |
| 138 | final coinType = _coinTypeFor(currency); |
| 139 | final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); |
| 140 | |
| 141 | for (final type in supportedTypes) { |
| 142 | final purpose = _purposeForType(type); |
| 143 | final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; |
| 144 | |
| 145 | mainHdByType[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; |
| 146 | sideHdByType[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; |
| 147 | } |
| 148 | } else { |
| 149 | // View-only wallet (xpub only) |
| 150 | for (final type in supportedTypes) { |
| 151 | mainHdByType[type] = mainHd; |
| 152 | sideHdByType[type] = sideHd; |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | int _purposeForType(BitcoinAddressType type) { |
| 158 | switch (type.value) { |
| 159 | case 'P2PKH': |
| 160 | return 44; |
| 161 | case 'P2SH/P2WPKH': |
| 162 | return 49; |
| 163 | case 'P2WPKH': |
| 164 | return 84; |
| 165 | case 'P2TR': |
| 166 | return 86; |
| 167 | default: |
| 168 | return 84; |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | int _coinTypeFor(CryptoCurrency cur) { |
| 173 | if (!network.isMainnet) return 1; |
| 174 | switch (cur) { |
| 175 | case CryptoCurrency.btc: |
| 176 | case CryptoCurrency.tbtc: |
| 177 | return 0; |
| 178 | case CryptoCurrency.ltc: |
| 179 | return 2; |
| 180 | case CryptoCurrency.bch: |
| 181 | return 145; |
| 182 | case CryptoCurrency.doge: |
| 183 | return 3; |
| 184 | default: |
| 185 | return 0; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// Returns the BIP32 account derivation path (m/purpose'/coinType'/0') for STANDARD addresses. |
| 190 | /// For LEGACY addresses, returns the wallet's legacy derivation base (derivationInfo.derivationPath) |
| 191 | /// which is already the account path used historically (e.g. m/0' or m/84'/0'/0'). |
| 192 | String _accountDerivationPathForRecord(BaseBitcoinAddressRecord record) { |
| 193 | if (derivationInfo.derivationType == DerivationType.electrum) { |
| 194 | return derivationInfo.derivationPath ?? electrum_path; // m/0' |
| 195 | } |
| 196 | |
| 197 | if (record.isLegacyDerivation) { |
| 198 | return derivationInfo.derivationPath ?? electrum_path; |
| 199 | } |
| 200 | |
| 201 | final coinType = _coinTypeFor(currency); |
| 202 | final purpose = _purposeForType(record.type); |
| 203 | final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); |
| 204 | return "m/$purpose'/$coinType'/$accountIndex'"; |
| 205 | } |
| 206 | |
| 207 | List<BitcoinAddressType> supportedAddressTypes(WalletType type) { |
| 208 | switch (type) { |
| 209 | case WalletType.bitcoin: |
| 210 | return BITCOIN_ADDRESS_TYPES; |
| 211 | case WalletType.bitcoinCash: |
| 212 | return BITCOIN_CASH_ADDRESS_TYPES; |
| 213 | case WalletType.dogecoin: |
| 214 | return DOGECOIN_ADDRESS_TYPES; |
| 215 | case WalletType.litecoin: |
| 216 | return LITECOIN_ADDRESS_TYPES; |
| 217 | default: |
| 218 | return BITCOIN_ADDRESS_TYPES; |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | static Bip32Slip10Secp256k1 getAccountHDWallet( |
| 223 | CryptoCurrency? currency, |
| 224 | BasedUtxoNetwork network, |
| 225 | Uint8List? seedBytes, |
| 226 | String? xpub, |
| 227 | DerivationInfo? derivationInfo, |
| 228 | HardwareWalletType? hardwareWalletType) { |
| 229 | if (seedBytes == null && xpub == null) { |
| 230 | throw Exception( |
| 231 | "To create a Wallet you need either a seed or an xpub. This should not happen"); |
| 232 | } |
| 233 | |
| 234 | if (seedBytes != null) { |
| 235 | switch (currency) { |
| 236 | case CryptoCurrency.btc: |
| 237 | case CryptoCurrency.ltc: |
| 238 | case CryptoCurrency.tbtc: |
| 239 | return Bip32Slip10Secp256k1.fromSeed( |
| 240 | seedBytes, getKeyNetVersion(network, hardwareWalletType)) |
| 241 | .derivePath( |
| 242 | _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) |
| 243 | as Bip32Slip10Secp256k1; |
| 244 | case CryptoCurrency.bch: |
| 245 | return bitcoinCashHDWallet(seedBytes); |
| 246 | case CryptoCurrency.doge: |
| 247 | return dogecoinHDWallet(seedBytes); |
| 248 | default: |
| 249 | throw Exception("Unsupported currency"); |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | return Bip32Slip10Secp256k1.fromExtendedKey( |
| 254 | xpub!, getKeyNetVersion(network, hardwareWalletType)); |
| 255 | } |
| 256 | |
| 257 | static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => |
| 258 | Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; |
| 259 | |
| 260 | static Bip32Slip10Secp256k1 dogecoinHDWallet(Uint8List seedBytes) => |
| 261 | Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/3'/0'") as Bip32Slip10Secp256k1; |
| 262 | |
| 263 | static int estimatedTransactionSize(int inputsCount, int outputsCounts) => |
| 264 | inputsCount * 68 + outputsCounts * 34 + 10; |
| 265 | |
| 266 | // vbytes an input of the given script type adds to a transaction. The generic |
| 267 | // estimate above assumes P2WPKH (68); legacy and taproot inputs differ enough |
| 268 | // to break changeless-match arithmetic if not accounted for. |
| 269 | static int estimatedInputSize(BitcoinAddressType type) { |
| 270 | if (type == P2pkhAddressType.p2pkh) return 148; |
| 271 | if (type == P2shAddressType.p2wpkhInP2sh) return 91; |
| 272 | if (type == SegwitAddresType.p2tr) return 58; |
| 273 | if (type == SegwitAddresType.p2wsh) return 105; |
| 274 | if (type == SilentPaymentsAddresType.p2sp) return 58; // spent via taproot |
| 275 | return 68; |
| 276 | } |
| 277 | |
| 278 | // vbytes an output of the given script type adds to a transaction. Silent |
| 279 | // payment outputs are delivered as taproot. |
| 280 | static int estimatedOutputSize(BitcoinAddressType type) { |
| 281 | if (type == P2pkhAddressType.p2pkh) return 34; |
| 282 | if (type == P2shAddressType.p2wpkhInP2sh) return 32; |
| 283 | if (type == SegwitAddresType.p2tr) return 43; |
| 284 | if (type == SegwitAddresType.p2wsh) return 43; |
| 285 | if (type == SilentPaymentsAddresType.p2sp) return 43; |
| 286 | return 31; |
| 287 | } |
| 288 | |
| 289 | // Parses the account index from a BIP-44/49/84/86 derivation path. |
| 290 | // e.g. "m/84'/0'/1'" → 1. Returns 0 for unrecognised formats. |
| 291 | static int _parseAccountIndex(String? derivationPath) { |
| 292 | if (derivationPath == null) return 0; |
| 293 | final parts = derivationPath.split('/'); |
| 294 | if (parts.length < 4) return 0; |
| 295 | return int.tryParse(parts[3].replaceAll("'", "")) ?? 0; |
| 296 | } |
| 297 | |
| 298 | static Bip32KeyNetVersions? getKeyNetVersion(BasedUtxoNetwork network, |
| 299 | [HardwareWalletType? hardwareWalletType]) { |
| 300 | switch (network) { |
| 301 | case LitecoinNetwork.mainnet: |
| 302 | if ([HardwareWalletType.ledger, HardwareWalletType.trezor].contains(hardwareWalletType)) |
| 303 | return Bip44Conf.litecoinMainNet.altKeyNetVer; |
| 304 | return null; |
| 305 | default: |
| 306 | return null; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | static Bip32Slip10Secp256k1? getMasterHD(Uint8List? seedBytes, |
| 311 | [BasedUtxoNetwork? network, HardwareWalletType? hardwareWalletType]) { |
| 312 | if (seedBytes == null) return null; |
| 313 | |
| 314 | return Bip32Slip10Secp256k1.fromSeed( |
| 315 | seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null); |
| 316 | } |
| 317 | |
| 318 | static const int addressHistoryChunkSize = 150; |
| 319 | static const int transactionChunkSize = 150; |
| 320 | static const int inputTransactionChunkSize = 150; |
| 321 | static const int discoveryHistoryChunkSize = 20; |
| 322 | |
| 323 | static const int transactionBatchTimeoutMs = 15000; |
| 324 | |
| 325 | static const int batchTestTimeoutMs = 4000; |
| 326 | static const int batchTestHashesCount = 2; |
| 327 | |
| 328 | static const bool useBatchForHistory = true; |
| 329 | |
| 330 | @observable |
| 331 | bool? alwaysScan; |
| 332 | |
| 333 | @computed |
| 334 | bool get useLightning => _useLightning && LightningWallet.isAvailable; |
| 335 | |
| 336 | set useLightning(bool val) => _useLightning = val && LightningWallet.isAvailable; |
| 337 | |
| 338 | @observable |
| 339 | bool _useLightning; |
| 340 | |
| 341 | final Bip32Slip10Secp256k1? _masterHD; |
| 342 | final Bip32Slip10Secp256k1 accountHD; |
| 343 | final String? _mnemonic; |
| 344 | |
| 345 | late final Map<BitcoinAddressType, Bip32Slip10Secp256k1> mainHdByType; |
| 346 | late final Map<BitcoinAddressType, Bip32Slip10Secp256k1> sideHdByType; |
| 347 | |
| 348 | Bip32Slip10Secp256k1 get mainHd => accountHD.childKey(Bip32KeyIndex(0)); |
| 349 | |
| 350 | Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1)); |
| 351 | |
| 352 | final EncryptionFileUtils encryptionFileUtils; |
| 353 | |
| 354 | @override |
| 355 | final String? passphrase; |
| 356 | |
| 357 | @override |
| 358 | @observable |
| 359 | bool isEnabledAutoGenerateSubaddress; |
| 360 | |
| 361 | late electrum.ElectrumClient electrumClient; |
| 362 | Box<UnspentCoinsInfo> unspentCoinsInfo; |
| 363 | |
| 364 | @override |
| 365 | late ElectrumWalletAddresses walletAddresses; |
| 366 | |
| 367 | @override |
| 368 | @observable |
| 369 | late ObservableMap<CryptoCurrency, ElectrumBalance> balance; |
| 370 | |
| 371 | @override |
| 372 | @observable |
| 373 | SyncStatus syncStatus; |
| 374 | |
| 375 | Set<String> get addressesSet => walletAddresses.allAddresses |
| 376 | .where((element) => element.type != SegwitAddresType.mweb) |
| 377 | .map((addr) => addr.address) |
| 378 | .toSet(); |
| 379 | |
| 380 | List<String> get scriptHashes => walletAddresses.addressesByReceiveType |
| 381 | .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) |
| 382 | .map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network)) |
| 383 | .toList(); |
| 384 | |
| 385 | List<String> get publicScriptHashes => walletAddresses.allAddresses |
| 386 | .where((addr) => !addr.isHidden) |
| 387 | .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) |
| 388 | .map((addr) => addr.getScriptHash(network)) |
| 389 | .toList(); |
| 390 | |
| 391 | String get xpub => accountHD.publicKey.toExtended; |
| 392 | |
| 393 | bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true; |
| 394 | |
| 395 | @override |
| 396 | String? get seed => _mnemonic; |
| 397 | |
| 398 | @override |
| 399 | WalletKeysData get walletKeysData => |
| 400 | WalletKeysData(mnemonic: _mnemonic, xPub: xpub, passphrase: passphrase); |
| 401 | |
| 402 | @override |
| 403 | String get password => _password; |
| 404 | |
| 405 | BasedUtxoNetwork network; |
| 406 | |
| 407 | @override |
| 408 | bool isTestnet; |
| 409 | |
| 410 | @override |
| 411 | bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty; |
| 412 | |
| 413 | @observable |
| 414 | bool nodeSupportsSilentPayments = true; |
| 415 | @observable |
| 416 | bool silentPaymentsScanningActive = false; |
| 417 | |
| 418 | bool _isTryingToConnect = false; |
| 419 | bool? _isBatchSupported; |
| 420 | DateTime? _syncBenchmarkStartTime; |
| 421 | |
| 422 | Completer<SharedPreferences> sharedPrefs = Completer(); |
| 423 | |
| 424 | Future<bool> checkIfMempoolAPIIsEnabled() async { |
| 425 | bool isMempoolAPIEnabled = (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true; |
| 426 | return isMempoolAPIEnabled; |
| 427 | } |
| 428 | |
| 429 | @action |
| 430 | Future<void> setSilentPaymentsScanning(bool active) async { |
| 431 | silentPaymentsScanningActive = active; |
| 432 | |
| 433 | if (active) { |
| 434 | syncStatus = AttemptingScanSyncStatus(); |
| 435 | |
| 436 | final tip = await getUpdatedChainTip(); |
| 437 | |
| 438 | if (tip == walletInfo.restoreHeight) { |
| 439 | syncStatus = SyncedTipSyncStatus(tip); |
| 440 | return; |
| 441 | } |
| 442 | |
| 443 | if (tip > walletInfo.restoreHeight) { |
| 444 | _setListeners(walletInfo.restoreHeight, chainTipParam: currentChainTip); |
| 445 | } |
| 446 | } else { |
| 447 | alwaysScan = false; |
| 448 | |
| 449 | _isolate?.then((value) => value.kill(priority: Isolate.immediate)); |
| 450 | |
| 451 | if (electrumClient.isConnected) { |
| 452 | syncStatus = SyncedSyncStatus(); |
| 453 | } else { |
| 454 | syncStatus = NotConnectedSyncStatus(); |
| 455 | } |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | int? currentChainTip; |
| 460 | |
| 461 | Future<int> getCurrentChainTip() async { |
| 462 | if ((currentChainTip ?? 0) > 0) { |
| 463 | return currentChainTip!; |
| 464 | } |
| 465 | currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; |
| 466 | |
| 467 | return currentChainTip!; |
| 468 | } |
| 469 | |
| 470 | Future<int> getUpdatedChainTip() async { |
| 471 | final newTip = await electrumClient.getCurrentBlockChainTip(); |
| 472 | if (newTip != null && newTip > (currentChainTip ?? 0)) { |
| 473 | currentChainTip = newTip; |
| 474 | } |
| 475 | return currentChainTip ?? 0; |
| 476 | } |
| 477 | |
| 478 | /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for |
| 479 | /// `BitcoinTransactionBuilder`. |
| 480 | Future<List<int>> _antiFeeSnipingLocktime() async { |
| 481 | return locktimeToBytes(antiFeeSnipingLocktime( |
| 482 | chainTip: await getCurrentChainTip(), |
| 483 | synced: syncStatus is SyncedSyncStatus, |
| 484 | )); |
| 485 | } |
| 486 | |
| 487 | @override |
| 488 | BitcoinWalletKeys get keys { |
| 489 | String? wif; |
| 490 | String? privateKey; |
| 491 | String? publicKey; |
| 492 | |
| 493 | final hd = mainHdByType[SegwitAddresType.p2wpkh] ?? mainHd; |
| 494 | |
| 495 | try { |
| 496 | wif = WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer); |
| 497 | } catch (_) {} |
| 498 | try { |
| 499 | privateKey = hd.privateKey.toHex(); |
| 500 | } catch (_) {} |
| 501 | try { |
| 502 | publicKey = hd.publicKey.toHex(); |
| 503 | } catch (_) {} |
| 504 | |
| 505 | return BitcoinWalletKeys( |
| 506 | wif: wif ?? '', |
| 507 | privateKey: privateKey ?? '', |
| 508 | publicKey: publicKey ?? '', |
| 509 | xpub: xpub, |
| 510 | masterFingerprint: _masterHD?.fingerPrint.toHex() ?? '', |
| 511 | ); |
| 512 | } |
| 513 | |
| 514 | String _password; |
| 515 | List<BitcoinUnspent> unspentCoins; |
| 516 | List<int> _feeRates; |
| 517 | |
| 518 | // ignore: prefer_final_fields |
| 519 | Map<String, BehaviorSubject<Object>?> _scripthashesUpdateSubject; |
| 520 | |
| 521 | // ignore: prefer_final_fields |
| 522 | BehaviorSubject<Object>? _chainTipUpdateSubject; |
| 523 | bool _isTransactionUpdating; |
| 524 | Future<Isolate>? _isolate; |
| 525 | |
| 526 | void Function(FlutterErrorDetails)? _onError; |
| 527 | Timer? _autoSaveTimer; |
| 528 | StreamSubscription<dynamic>? _receiveStream; |
| 529 | Timer? _updateFeeRateTimer; |
| 530 | static const int _autoSaveInterval = 1; |
| 531 | |
| 532 | Future<void> init() async { |
| 533 | await walletAddresses.init(); |
| 534 | await transactionHistory.init(); |
| 535 | await cleanUpDuplicateUnspentCoins(); |
| 536 | await save(); |
| 537 | |
| 538 | _autoSaveTimer = |
| 539 | Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save()); |
| 540 | } |
| 541 | |
| 542 | @action |
| 543 | Future<void> _setListeners(int height, |
| 544 | {int? chainTipParam, bool? doSingleScan, List<int>? rescanHeights}) async { |
| 545 | if (this is! BitcoinWallet) return; |
| 546 | if (isHardwareWallet) return; |
| 547 | if (seed?.isEmpty ?? true) return; |
| 548 | |
| 549 | final chainTip = chainTipParam ?? await getUpdatedChainTip(); |
| 550 | final shouldUpdateSyncStatus = rescanHeights == null || rescanHeights.isEmpty; |
| 551 | |
| 552 | if (chainTip == height) { |
| 553 | syncStatus = SyncedSyncStatus(); |
| 554 | return; |
| 555 | } |
| 556 | |
| 557 | if (shouldUpdateSyncStatus) syncStatus = AttemptingScanSyncStatus(); |
| 558 | |
| 559 | if (_isolate != null) { |
| 560 | final runningIsolate = await _isolate!; |
| 561 | runningIsolate.kill(priority: Isolate.immediate); |
| 562 | } |
| 563 | |
| 564 | final appDir = await getAppDir(); |
| 565 | String debugLogPath = "${appDir.path}/logs/debug.log"; |
| 566 | |
| 567 | final receivePort = ReceivePort(); |
| 568 | _isolate = Isolate.spawn( |
| 569 | _handleScanSilentPayments, |
| 570 | ScanData( |
| 571 | sendPort: receivePort.sendPort, |
| 572 | silentAddress: walletAddresses.silentAddress!, |
| 573 | masterHD: _masterHD!, |
| 574 | network: network, |
| 575 | height: height, |
| 576 | chainTip: chainTip, |
| 577 | electrumClient: electrum.ElectrumClient(), |
| 578 | transactionHistoryIds: transactionHistory.transactions.keys.toList(), |
| 579 | node: (await getNodeSupportsSilentPayments()) == true |
| 580 | ? ScanNode(node!.uri, node!.useSSL) |
| 581 | : null, |
| 582 | labels: walletAddresses.labels, |
| 583 | labelIndexes: walletAddresses.silentAddresses |
| 584 | .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) |
| 585 | .map((addr) => addr.index) |
| 586 | .toList(), |
| 587 | isSingleScan: doSingleScan ?? false, |
| 588 | debugLogPath: debugLogPath, |
| 589 | rescanHeights: rescanHeights, |
| 590 | ), |
| 591 | ); |
| 592 | |
| 593 | await _receiveStream?.cancel(); |
| 594 | _receiveStream = receivePort.listen((var message) async { |
| 595 | if (message is Map<String, ElectrumTransactionInfo>) { |
| 596 | for (final map in message.entries) { |
| 597 | final txid = map.key; |
| 598 | final tx = map.value; |
| 599 | |
| 600 | if (tx.unspents != null) { |
| 601 | final existingTxInfo = transactionHistory.transactions[txid]; |
| 602 | final txAlreadyExisted = existingTxInfo != null; |
| 603 | |
| 604 | // Updating tx after re-scanned |
| 605 | if (txAlreadyExisted) { |
| 606 | existingTxInfo.amount = tx.amount; |
| 607 | existingTxInfo.confirmations = tx.confirmations; |
| 608 | existingTxInfo.height = tx.height; |
| 609 | existingTxInfo.date = tx.date; |
| 610 | existingTxInfo.isReceivedSilentPayment = tx.isReceivedSilentPayment; |
| 611 | existingTxInfo.direction = tx.direction; |
| 612 | existingTxInfo.isPending = tx.isPending; |
| 613 | existingTxInfo.unspents = tx.unspents; |
| 614 | |
| 615 | final newUnspents = tx.unspents! |
| 616 | .where((unspent) => !(existingTxInfo.unspents?.any((element) => |
| 617 | element.hash.contains(unspent.hash) && |
| 618 | element.vout == unspent.vout && |
| 619 | element.value == unspent.value) ?? |
| 620 | false)) |
| 621 | .toList(); |
| 622 | |
| 623 | if (newUnspents.isNotEmpty) { |
| 624 | newUnspents.forEach(_updateSilentAddressRecord); |
| 625 | |
| 626 | existingTxInfo.unspents ??= []; |
| 627 | existingTxInfo.unspents!.addAll(newUnspents); |
| 628 | |
| 629 | final newAmount = newUnspents.length > 1 |
| 630 | ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent) |
| 631 | : newUnspents[0].value; |
| 632 | |
| 633 | if (existingTxInfo.direction == TransactionDirection.incoming) { |
| 634 | existingTxInfo.amount += Money.fromInt(newAmount, currency); |
| 635 | } |
| 636 | |
| 637 | // Updates existing TX |
| 638 | transactionHistory.addOne(existingTxInfo); |
| 639 | // Update balance record |
| 640 | balance[currency]!.confirmed += Money.fromInt(newAmount, currency); |
| 641 | } |
| 642 | } else { |
| 643 | // else: First time seeing this TX after scanning |
| 644 | tx.unspents!.forEach(_updateSilentAddressRecord); |
| 645 | |
| 646 | // Add new TX record |
| 647 | transactionHistory.addMany(message); |
| 648 | |
| 649 | // Update balance record |
| 650 | balance[currency]!.confirmed += tx.amount; |
| 651 | |
| 652 | await save(); |
| 653 | } |
| 654 | |
| 655 | await updateAllUnspents(); |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | |
| 660 | if (message is SyncResponse) { |
| 661 | if (message.syncStatus is UnsupportedSyncStatus) { |
| 662 | nodeSupportsSilentPayments = false; |
| 663 | } |
| 664 | |
| 665 | if (message.syncStatus is SyncingSyncStatus) { |
| 666 | var status = message.syncStatus as SyncingSyncStatus; |
| 667 | if (shouldUpdateSyncStatus) syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc); |
| 668 | } else { |
| 669 | if (shouldUpdateSyncStatus) syncStatus = message.syncStatus; |
| 670 | } |
| 671 | |
| 672 | await walletInfo.updateRestoreHeight(message.height); |
| 673 | } |
| 674 | }); |
| 675 | } |
| 676 | |
| 677 | void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) { |
| 678 | final silentAddress = walletAddresses.silentAddress!; |
| 679 | final silentPaymentAddress = SilentPaymentAddress( |
| 680 | version: silentAddress.version, |
| 681 | B_scan: silentAddress.B_scan, |
| 682 | B_spend: unspent.silentPaymentLabel != null |
| 683 | ? silentAddress.B_spend.tweakAdd( |
| 684 | BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)), |
| 685 | ) |
| 686 | : silentAddress.B_spend, |
| 687 | ); |
| 688 | |
| 689 | final addressRecord = walletAddresses.silentAddresses |
| 690 | .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); |
| 691 | addressRecord?.txCount += 1; |
| 692 | addressRecord?.balance += unspent.value; |
| 693 | |
| 694 | walletAddresses.addSilentAddresses( |
| 695 | [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord], |
| 696 | ); |
| 697 | } |
| 698 | |
| 699 | DateTime? _lastSilentPaymentsScan; |
| 700 | static const Duration _silentPaymentsScanDelay = Duration(minutes: 1); |
| 701 | |
| 702 | @action |
| 703 | @override |
| 704 | Future<void> startSync() async { |
| 705 | try { |
| 706 | if (syncStatus is SyncronizingSyncStatus) { |
| 707 | return; |
| 708 | } |
| 709 | |
| 710 | if (_syncBenchmarkStartTime == null) { |
| 711 | _syncBenchmarkStartTime = DateTime.now(); |
| 712 | printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}'); |
| 713 | } |
| 714 | |
| 715 | syncStatus = SyncronizingSyncStatus(); |
| 716 | |
| 717 | if (hasSilentPaymentsScanning) { |
| 718 | silentPaymentsScanningActive = alwaysScan ?? false; |
| 719 | await _setInitialHeight(); |
| 720 | |
| 721 | final now = DateTime.now(); |
| 722 | final shouldForceRescan = _lastSilentPaymentsScan == null || |
| 723 | now.difference(_lastSilentPaymentsScan!) >= _silentPaymentsScanDelay; |
| 724 | |
| 725 | // Timer prevents server failure and this infinite looping and requesting |
| 726 | if (shouldForceRescan) { |
| 727 | _lastSilentPaymentsScan = now; |
| 728 | |
| 729 | final rescanHeights = <int>[]; |
| 730 | |
| 731 | transactionHistory.transactions.values.forEach((tx) { |
| 732 | if (tx.unspents != null && tx.unspents!.isNotEmpty) |
| 733 | for (final unspent in tx.unspents!) { |
| 734 | if (unspent.silentPaymentTweak != null && tx.height != null && tx.height! > 0) { |
| 735 | rescanHeights.add(tx.height!); |
| 736 | break; |
| 737 | } |
| 738 | } |
| 739 | }); |
| 740 | |
| 741 | if (rescanHeights.isNotEmpty) |
| 742 | _setListeners(walletInfo.restoreHeight, rescanHeights: rescanHeights); |
| 743 | } |
| 744 | } |
| 745 | |
| 746 | await subscribeForUpdates(); |
| 747 | await checkIfBatchSupported(); |
| 748 | await updateTransactions(); |
| 749 | |
| 750 | await updateAllUnspents(); |
| 751 | await updateBalance(); |
| 752 | await updateFeeRates(); |
| 753 | |
| 754 | _updateFeeRateTimer ??= |
| 755 | Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); |
| 756 | |
| 757 | if (alwaysScan == true) { |
| 758 | setSilentPaymentsScanning(true); |
| 759 | } else { |
| 760 | if (syncStatus is LostConnectionSyncStatus) { |
| 761 | return; |
| 762 | } |
| 763 | |
| 764 | final syncEnd = DateTime.now(); |
| 765 | final totalMs = _syncBenchmarkStartTime != null |
| 766 | ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds |
| 767 | : 0; |
| 768 | |
| 769 | printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); |
| 770 | |
| 771 | _syncBenchmarkStartTime = null; |
| 772 | syncStatus = SyncedSyncStatus(); |
| 773 | } |
| 774 | } catch (e, stacktrace) { |
| 775 | final syncEnd = DateTime.now(); |
| 776 | final totalMs = _syncBenchmarkStartTime != null |
| 777 | ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds |
| 778 | : 0; |
| 779 | |
| 780 | printV(stacktrace); |
| 781 | printV("startSync $e"); |
| 782 | printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); |
| 783 | |
| 784 | _syncBenchmarkStartTime = null; |
| 785 | syncStatus = FailedSyncStatus(); |
| 786 | } |
| 787 | } |
| 788 | |
| 789 | static bool _isValidFeeRates(List<int> feeRates) => |
| 790 | feeRates.length == 3 && feeRates.every((rate) => rate > 0); |
| 791 | |
| 792 | @action |
| 793 | Future<void> updateFeeRates() async { |
| 794 | if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { |
| 795 | try { |
| 796 | final response = await ProxyWrapper() |
| 797 | .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended")) |
| 798 | .timeout(Duration(seconds: 15)); |
| 799 | |
| 800 | final result = json.decode(response.body) as Map<String, dynamic>; |
| 801 | final slowFee = (result['economyFee'] as num?)?.toInt() ?? 0; |
| 802 | int mediumFee = (result['hourFee'] as num?)?.toInt() ?? 0; |
| 803 | int fastFee = (result['fastestFee'] as num?)?.toInt() ?? 0; |
| 804 | if (slowFee == mediumFee) { |
| 805 | mediumFee++; |
| 806 | } |
| 807 | while (fastFee <= mediumFee) { |
| 808 | fastFee++; |
| 809 | } |
| 810 | _feeRates = [slowFee, mediumFee, fastFee]; |
| 811 | return; |
| 812 | } catch (e) { |
| 813 | printV(e); |
| 814 | } |
| 815 | } |
| 816 | |
| 817 | final feeRates = await electrumClient.feeRates(network: network); |
| 818 | if (_isValidFeeRates(feeRates)) { |
| 819 | _feeRates = feeRates; |
| 820 | } else if (isTestnet) { |
| 821 | _feeRates = [1, 1, 1]; |
| 822 | } |
| 823 | } |
| 824 | |
| 825 | Node? node; |
| 826 | |
| 827 | Future<bool> getNodeIsElectrs() async { |
| 828 | if (node == null) { |
| 829 | return false; |
| 830 | } |
| 831 | |
| 832 | final version = await electrumClient.version(); |
| 833 | |
| 834 | if (version.isNotEmpty) { |
| 835 | final server = version[0]; |
| 836 | |
| 837 | if (server.toLowerCase().contains('electrs')) { |
| 838 | node!.isElectrs = true; |
| 839 | // TODO figure out why condition was needed |
| 840 | // if (node!.isInBox) { |
| 841 | node!.save(); |
| 842 | // } |
| 843 | return node!.isElectrs!; |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | node!.isElectrs = false; |
| 848 | return node!.isElectrs!; |
| 849 | } |
| 850 | |
| 851 | Future<bool> getNodeSupportsSilentPayments() async { |
| 852 | // As of today (august 2024), only ElectrumRS supports silent payments |
| 853 | if (!(await getNodeIsElectrs())) { |
| 854 | return false; |
| 855 | } |
| 856 | |
| 857 | if (node == null) { |
| 858 | return false; |
| 859 | } |
| 860 | |
| 861 | try { |
| 862 | final tweaksResponse = await electrumClient.getTweaks(height: 0); |
| 863 | |
| 864 | if (tweaksResponse != null) { |
| 865 | node!.supportsSilentPayments = true; |
| 866 | node!.save(); |
| 867 | return node!.supportsSilentPayments!; |
| 868 | } |
| 869 | } on electrum.RequestFailedTimeoutException catch (_) { |
| 870 | node!.supportsSilentPayments = false; |
| 871 | node!.save(); |
| 872 | return node!.supportsSilentPayments!; |
| 873 | } catch (_) {} |
| 874 | |
| 875 | node!.supportsSilentPayments = false; |
| 876 | node!.save(); |
| 877 | return node!.supportsSilentPayments!; |
| 878 | } |
| 879 | |
| 880 | @action |
| 881 | @override |
| 882 | Future<void> connectToNode({required Node node}) async { |
| 883 | this.node = node; |
| 884 | _isBatchSupported = null; |
| 885 | |
| 886 | if (syncStatus is ConnectingSyncStatus) return; |
| 887 | |
| 888 | try { |
| 889 | syncStatus = ConnectingSyncStatus(); |
| 890 | |
| 891 | await _receiveStream?.cancel(); |
| 892 | await electrumClient.close(); |
| 893 | _isBatchSupported = null; |
| 894 | |
| 895 | electrumClient.onConnectionStatusChange = _onConnectionStatusChange; |
| 896 | |
| 897 | await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); |
| 898 | } catch (e, stacktrace) { |
| 899 | printV(stacktrace); |
| 900 | printV("connectToNode $e"); |
| 901 | syncStatus = FailedSyncStatus(); |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | BigInt get networkDustAmount => BigInt.from(546); |
| 906 | |
| 907 | bool _isBelowDust(BigInt amount) => |
| 908 | amount <= networkDustAmount && network != BitcoinNetwork.testnet; |
| 909 | |
| 910 | // Random draw priority per outpoint. Assigned lazily with a secure RNG and kept |
| 911 | // until the next createTransaction call, so the recursive estimate passes of one |
| 912 | // transaction build all see the same input order (reshuffling between passes made |
| 913 | // fee estimation unstable). Cleared per transaction so each send is a fresh draw. |
| 914 | final Random _coinSelectionRng = Random.secure(); |
| 915 | final Map<String, int> _coinSelectionOrder = {}; |
| 916 | |
| 917 | int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent( |
| 918 | '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32)); |
| 919 | |
| 920 | UtxoDetails _createUTXOS({ |
| 921 | required bool sendAll, |
| 922 | required bool paysToSilentPayment, |
| 923 | int credentialsAmount = 0, |
| 924 | int? inputsCount, |
| 925 | int feeRate = 0, |
| 926 | int? outputsVBytes, |
| 927 | UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, |
| 928 | }) { |
| 929 | List<UtxoWithAddress> utxos = []; |
| 930 | List<Outpoint> vinOutpoints = []; |
| 931 | List<ECPrivateInfo> inputPrivKeyInfos = []; |
| 932 | final publicKeys = <String, PublicKeyWithDerivationPath>{}; |
| 933 | int allInputsAmount = 0; |
| 934 | bool spendsSilentPayment = false; |
| 935 | bool spendsUnconfirmedTX = false; |
| 936 | |
| 937 | int leftAmount = credentialsAmount; |
| 938 | var availableInputs = unspentCoins.where((utx) { |
| 939 | if (!utx.isSending || utx.isFrozen) { |
| 940 | return false; |
| 941 | } |
| 942 | |
| 943 | switch (coinTypeToSpendFrom) { |
| 944 | case UnspentCoinType.mweb: |
| 945 | return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb; |
| 946 | case UnspentCoinType.nonMweb: |
| 947 | return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb; |
| 948 | case UnspentCoinType.any: |
| 949 | case UnspentCoinType.lightning: |
| 950 | return true; |
| 951 | } |
| 952 | }).toList(); |
| 953 | final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); |
| 954 | |
| 955 | // Single Random Draw: order the pool by each coin's random priority so selection is |
| 956 | // non-deterministic, removing the predictable address/scan order (a fingerprint). |
| 957 | // The priority is stable across the repeated calls of one transaction build. |
| 958 | // MWEB coins are kept last afterwards. |
| 959 | availableInputs.sort((a, b) { |
| 960 | final byPriority = _coinSelectionPriority(a).compareTo(_coinSelectionPriority(b)); |
| 961 | if (byPriority != 0) return byPriority; |
| 962 | return '${a.hash}:${a.vout}'.compareTo('${b.hash}:${b.vout}'); |
| 963 | }); |
| 964 | availableInputs = [ |
| 965 | ...availableInputs.where((u) => u.bitcoinAddressRecord.type != SegwitAddresType.mweb), |
| 966 | ...availableInputs.where((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb), |
| 967 | ]; |
| 968 | |
| 969 | // Branch-and-bound: prefer an input set whose excess over the amount plus its own fee |
| 970 | // stays below dust, so the caller drops the change output and the send is changeless. |
| 971 | // When no such set exists, the shuffled pool below acts as a single random draw. |
| 972 | final canTryChangeless = !sendAll && |
| 973 | inputsCount == null && |
| 974 | credentialsAmount > 0 && |
| 975 | feeRate > 0 && |
| 976 | outputsVBytes != null && |
| 977 | outputsVBytes > 0 && |
| 978 | !availableInputs.any((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb); |
| 979 | if (canTryChangeless) { |
| 980 | final match = changelessMatch( |
| 981 | values: [for (final u in availableInputs) u.value], |
| 982 | // estimatedTransactionSize(0, 0) is the fixed tx overhead (version, |
| 983 | // counters, locktime); the outputs' own vbytes come pre-computed per type. |
| 984 | target: credentialsAmount + (estimatedTransactionSize(0, 0) + outputsVBytes!) * feeRate, |
| 985 | inputCosts: [ |
| 986 | for (final u in availableInputs) |
| 987 | estimatedInputSize(u.bitcoinAddressRecord.type) * feeRate |
| 988 | ], |
| 989 | window: networkDustAmount.toInt(), |
| 990 | ); |
| 991 | if (match != null) { |
| 992 | final chosen = match.indices.toSet(); |
| 993 | availableInputs = [ |
| 994 | for (final i in match.indices) availableInputs[i], |
| 995 | for (var i = 0; i < availableInputs.length; i++) |
| 996 | if (!chosen.contains(i)) availableInputs[i], |
| 997 | ]; |
| 998 | inputsCount = match.indices.length; |
| 999 | } |
| 1000 | } |
| 1001 | |
| 1002 | for (int i = 0; i < availableInputs.length; i++) { |
| 1003 | final utx = availableInputs[i]; |
| 1004 | if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; |
| 1005 | |
| 1006 | if (paysToSilentPayment) { |
| 1007 | // Check inputs for shared secret derivation |
| 1008 | if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) { |
| 1009 | throw BitcoinTransactionSilentPaymentsNotSupported(); |
| 1010 | } |
| 1011 | } |
| 1012 | |
| 1013 | allInputsAmount += utx.value; |
| 1014 | leftAmount = leftAmount - utx.value; |
| 1015 | |
| 1016 | final address = RegexUtils.addressTypeFromStr(utx.address, network); |
| 1017 | ECPrivate? privkey; |
| 1018 | bool? isSilentPayment = false; |
| 1019 | final hd = _hdFor(record: utx.bitcoinAddressRecord); |
| 1020 | |
| 1021 | if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { |
| 1022 | final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; |
| 1023 | privkey = ECPrivate.fromHex( |
| 1024 | _masterHD!.derivePath(unspentAddress.spendDerivationPath).privateKey.toHex()) |
| 1025 | .tweakAdd( |
| 1026 | BigintUtils.fromBytes(BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!)), |
| 1027 | ); |
| 1028 | spendsSilentPayment = true; |
| 1029 | isSilentPayment = true; |
| 1030 | } else if (!isHardwareWallet && keys.privateKey.isNotEmpty) { |
| 1031 | privkey = |
| 1032 | generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); |
| 1033 | } |
| 1034 | |
| 1035 | vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); |
| 1036 | String pubKeyHex; |
| 1037 | |
| 1038 | if (privkey != null) { |
| 1039 | inputPrivKeyInfos.add(ECPrivateInfo( |
| 1040 | privkey, |
| 1041 | address.type == SegwitAddresType.p2tr, |
| 1042 | tweak: !isSilentPayment, |
| 1043 | )); |
| 1044 | |
| 1045 | pubKeyHex = privkey.getPublic().toHex(); |
| 1046 | } else { |
| 1047 | pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex(); |
| 1048 | } |
| 1049 | |
| 1050 | final baseDerivationPath = _accountDerivationPathForRecord(utx.bitcoinAddressRecord); |
| 1051 | |
| 1052 | final derivationPath = "${_hardenedDerivationPath(baseDerivationPath)}" |
| 1053 | "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" |
| 1054 | "/${utx.bitcoinAddressRecord.index}"; |
| 1055 | publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); |
| 1056 | |
| 1057 | utxos.add( |
| 1058 | UtxoWithAddress( |
| 1059 | utxo: BitcoinUtxo( |
| 1060 | txHash: utx.hash, |
| 1061 | value: BigInt.from(utx.value), |
| 1062 | vout: utx.vout, |
| 1063 | scriptType: _getScriptType(address), |
| 1064 | isSilentPayment: isSilentPayment, |
| 1065 | ), |
| 1066 | ownerDetails: UtxoAddressDetails( |
| 1067 | publicKey: pubKeyHex, |
| 1068 | address: address, |
| 1069 | ), |
| 1070 | ), |
| 1071 | ); |
| 1072 | |
| 1073 | // sendAll continues for all inputs |
| 1074 | if (!sendAll) { |
| 1075 | bool amountIsAcquired = leftAmount <= 0; |
| 1076 | if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { |
| 1077 | break; |
| 1078 | } |
| 1079 | } |
| 1080 | } |
| 1081 | |
| 1082 | if (utxos.isEmpty) { |
| 1083 | throw BitcoinTransactionNoInputsException(); |
| 1084 | } |
| 1085 | |
| 1086 | return UtxoDetails( |
| 1087 | availableInputs: availableInputs, |
| 1088 | unconfirmedCoins: unconfirmedCoins, |
| 1089 | utxos: utxos, |
| 1090 | vinOutpoints: vinOutpoints, |
| 1091 | inputPrivKeyInfos: inputPrivKeyInfos, |
| 1092 | publicKeys: publicKeys, |
| 1093 | allInputsAmount: allInputsAmount, |
| 1094 | spendsSilentPayment: spendsSilentPayment, |
| 1095 | spendsUnconfirmedTX: spendsUnconfirmedTX, |
| 1096 | ); |
| 1097 | } |
| 1098 | |
| 1099 | Future<EstimatedTxResult> estimateSendAllTx( |
| 1100 | List<BitcoinOutput> outputs, |
| 1101 | int feeRate, { |
| 1102 | String? memo, |
| 1103 | bool hasSilentPayment = false, |
| 1104 | UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, |
| 1105 | }) async { |
| 1106 | final utxoDetails = _createUTXOS( |
| 1107 | sendAll: true, |
| 1108 | paysToSilentPayment: hasSilentPayment, |
| 1109 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1110 | ); |
| 1111 | |
| 1112 | int fee = await calcFee( |
| 1113 | utxos: utxoDetails.utxos, |
| 1114 | outputs: outputs, |
| 1115 | network: network, |
| 1116 | memo: memo, |
| 1117 | feeRate: feeRate, |
| 1118 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1119 | vinOutpoints: utxoDetails.vinOutpoints, |
| 1120 | ); |
| 1121 | |
| 1122 | if (fee <= 0) { |
| 1123 | throw BitcoinTransactionNoFeeException(); |
| 1124 | } |
| 1125 | |
| 1126 | // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change |
| 1127 | final amount = BigInt.from(utxoDetails.allInputsAmount - fee); |
| 1128 | |
| 1129 | if (amount <= BigInt.zero) { |
| 1130 | throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); |
| 1131 | } |
| 1132 | |
| 1133 | // Attempting to send less than the dust limit |
| 1134 | if (_isBelowDust(amount)) { |
| 1135 | throw BitcoinTransactionNoDustException(); |
| 1136 | } |
| 1137 | |
| 1138 | if (outputs.length == 1) { |
| 1139 | outputs[0] = BitcoinOutput( |
| 1140 | address: outputs.last.address, |
| 1141 | value: amount, |
| 1142 | isSilentPayment: hasSilentPayment, |
| 1143 | ); |
| 1144 | } |
| 1145 | |
| 1146 | return EstimatedTxResult( |
| 1147 | utxos: utxoDetails.utxos, |
| 1148 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1149 | publicKeys: utxoDetails.publicKeys, |
| 1150 | fee: Money.fromInt(fee, currency), |
| 1151 | amount: Money(amount, currency), |
| 1152 | isSendAll: true, |
| 1153 | hasChange: false, |
| 1154 | memo: memo, |
| 1155 | spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, |
| 1156 | spendsSilentPayment: utxoDetails.spendsSilentPayment, |
| 1157 | ); |
| 1158 | } |
| 1159 | |
| 1160 | Future<EstimatedTxResult> estimateTxForAmount( |
| 1161 | Money credentialsAmount, |
| 1162 | List<BitcoinOutput> outputs, |
| 1163 | List<BitcoinOutput> updatedOutputs, |
| 1164 | int feeRate, { |
| 1165 | int? inputsCount, |
| 1166 | String? memo, |
| 1167 | bool? useUnconfirmed, |
| 1168 | bool hasSilentPayment = false, |
| 1169 | UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, |
| 1170 | }) async { |
| 1171 | // Attempting to send less than the dust limit |
| 1172 | if (_isBelowDust(credentialsAmount.amount)) { |
| 1173 | throw BitcoinTransactionNoDustException(); |
| 1174 | } |
| 1175 | |
| 1176 | // if mweb isn't enabled, don't consider spending mweb coins: |
| 1177 | if (this is LitecoinWallet) { |
| 1178 | var mwebEnabled = (this as LitecoinWallet).mwebEnabled; |
| 1179 | if (!mwebEnabled) { |
| 1180 | coinTypeToSpendFrom = UnspentCoinType.nonMweb; |
| 1181 | } |
| 1182 | } |
| 1183 | |
| 1184 | // If there is only one output, and the amount to send is more than the max spendable amount |
| 1185 | // then it is actually a send all transaction |
| 1186 | |
| 1187 | if (outputs.length == 1) { |
| 1188 | final maxSpendable = await _maxSpendableNoChangeAmount( |
| 1189 | initialOutput: outputs.first, |
| 1190 | feeRate: feeRate, |
| 1191 | memo: memo, |
| 1192 | hasSilentPayment: hasSilentPayment, |
| 1193 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1194 | ); |
| 1195 | if (credentialsAmount > maxSpendable) { |
| 1196 | throw BitcoinTransactionWrongBalanceException(); |
| 1197 | } |
| 1198 | if (credentialsAmount >= maxSpendable) { |
| 1199 | final estimateOutput = [ |
| 1200 | BitcoinOutput( |
| 1201 | address: outputs.first.address, |
| 1202 | value: BigInt.zero, |
| 1203 | isSilentPayment: outputs.first.isSilentPayment, |
| 1204 | ) |
| 1205 | ]; |
| 1206 | return estimateSendAllTx( |
| 1207 | estimateOutput, |
| 1208 | feeRate, |
| 1209 | memo: memo, |
| 1210 | hasSilentPayment: hasSilentPayment, |
| 1211 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1212 | ); |
| 1213 | } |
| 1214 | } |
| 1215 | |
| 1216 | // Per-type output sizes for the changeless target. MWEB outputs follow a |
| 1217 | // different size model entirely, so they disable the changeless path (null). |
| 1218 | int? outputsVBytes = 0; |
| 1219 | for (final out in outputs) { |
| 1220 | final type = |
| 1221 | out.isSilentPayment == true ? SilentPaymentsAddresType.p2sp : _getScriptType(out.address); |
| 1222 | if (type == SegwitAddresType.mweb) { |
| 1223 | outputsVBytes = null; |
| 1224 | break; |
| 1225 | } |
| 1226 | outputsVBytes = outputsVBytes! + estimatedOutputSize(type); |
| 1227 | } |
| 1228 | |
| 1229 | final utxoDetails = _createUTXOS( |
| 1230 | sendAll: false, |
| 1231 | credentialsAmount: credentialsAmount.amount.toInt(), |
| 1232 | inputsCount: inputsCount, |
| 1233 | feeRate: feeRate, |
| 1234 | outputsVBytes: outputsVBytes, |
| 1235 | paysToSilentPayment: hasSilentPayment, |
| 1236 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1237 | ); |
| 1238 | |
| 1239 | final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; |
| 1240 | final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && |
| 1241 | utxoDetails.utxos.length == |
| 1242 | utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; |
| 1243 | |
| 1244 | final amountLeftForChangeAndFee = |
| 1245 | utxoDetails.allInputsAmount - credentialsAmount.amount.toInt(); |
| 1246 | |
| 1247 | if (amountLeftForChangeAndFee <= 0) { |
| 1248 | if (!spendingAllCoins) { |
| 1249 | return estimateTxForAmount( |
| 1250 | credentialsAmount, |
| 1251 | outputs, |
| 1252 | updatedOutputs, |
| 1253 | feeRate, |
| 1254 | inputsCount: utxoDetails.utxos.length + 1, |
| 1255 | memo: memo, |
| 1256 | hasSilentPayment: hasSilentPayment, |
| 1257 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1258 | ); |
| 1259 | } |
| 1260 | |
| 1261 | throw BitcoinTransactionWrongBalanceException(); |
| 1262 | } |
| 1263 | |
| 1264 | final changeAddress = await walletAddresses.getChangeAddress( |
| 1265 | inputs: utxoDetails.availableInputs, |
| 1266 | outputs: updatedOutputs, |
| 1267 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1268 | ); |
| 1269 | final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); |
| 1270 | updatedOutputs.add(BitcoinOutput( |
| 1271 | address: address, |
| 1272 | value: BigInt.from(amountLeftForChangeAndFee), |
| 1273 | isChange: true, |
| 1274 | )); |
| 1275 | outputs.add(BitcoinOutput( |
| 1276 | address: address, |
| 1277 | value: BigInt.from(amountLeftForChangeAndFee), |
| 1278 | isChange: true, |
| 1279 | )); |
| 1280 | |
| 1281 | // Must match the address' account root (purpose/coinType) and legacy derivation when applicable. |
| 1282 | final changeBaseDerivationPath = _accountDerivationPathForRecord(changeAddress); |
| 1283 | final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}" |
| 1284 | "/${changeAddress.isHidden ? "1" : "0"}" |
| 1285 | "/${changeAddress.index}"; |
| 1286 | utxoDetails.publicKeys[address.pubKeyHash()] = |
| 1287 | PublicKeyWithDerivationPath('', changeDerivationPath); |
| 1288 | |
| 1289 | // calcFee updates the silent payment outputs to calculate the tx size accounting |
| 1290 | // for taproot addresses, but if more inputs are needed to make up for fees, |
| 1291 | // the silent payment outputs need to be recalculated for the new inputs |
| 1292 | var temp = outputs.map((output) => output).toList(); |
| 1293 | int fee = await calcFee( |
| 1294 | utxos: utxoDetails.utxos, |
| 1295 | // Always take only not updated bitcoin outputs here so for every estimation |
| 1296 | // the SP outputs are re-generated to the proper taproot addresses |
| 1297 | outputs: temp, |
| 1298 | network: network, |
| 1299 | memo: memo, |
| 1300 | feeRate: feeRate, |
| 1301 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1302 | vinOutpoints: utxoDetails.vinOutpoints, |
| 1303 | ); |
| 1304 | |
| 1305 | updatedOutputs.clear(); |
| 1306 | for (int i = 0; i < temp.length; i++) { |
| 1307 | final output = temp[i]; |
| 1308 | final oldOutput = outputs[i]; |
| 1309 | |
| 1310 | updatedOutputs.add(BitcoinOutput( |
| 1311 | address: output.address, |
| 1312 | value: output.value, |
| 1313 | isSilentPayment: oldOutput.isSilentPayment, |
| 1314 | isChange: output.isChange, |
| 1315 | )); |
| 1316 | } |
| 1317 | |
| 1318 | if (fee <= 0) { |
| 1319 | throw BitcoinTransactionNoFeeException(); |
| 1320 | } |
| 1321 | |
| 1322 | var amount = credentialsAmount; |
| 1323 | final lastOutput = updatedOutputs.last; |
| 1324 | final amountLeftForChange = BigInt.from(amountLeftForChangeAndFee - fee); |
| 1325 | |
| 1326 | if (_isBelowDust(amountLeftForChange)) { |
| 1327 | // If has change that is lower than dust, will end up with tx rejected by network rules |
| 1328 | // so remove the change amount |
| 1329 | updatedOutputs.removeLast(); |
| 1330 | outputs.removeLast(); |
| 1331 | |
| 1332 | // If the computed change is negative or below dust: |
| 1333 | // - negative: try a no-change tx (recalculate fee without change) |
| 1334 | // - non-negative but dust: drop change and add remainder to fee |
| 1335 | if (amountLeftForChange < BigInt.zero) { |
| 1336 | final tempNoChange = outputs.map((o) => o).toList(); |
| 1337 | final feeNoChange = await calcFee( |
| 1338 | utxos: utxoDetails.utxos, |
| 1339 | outputs: tempNoChange, |
| 1340 | network: network, |
| 1341 | memo: memo, |
| 1342 | feeRate: feeRate, |
| 1343 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1344 | vinOutpoints: utxoDetails.vinOutpoints, |
| 1345 | ); |
| 1346 | final leftover = |
| 1347 | utxoDetails.allInputsAmount - credentialsAmount.amount.toInt() - feeNoChange; |
| 1348 | |
| 1349 | if (leftover >= 0) { |
| 1350 | final finalFee = feeNoChange + leftover; // absorb tiny remainder |
| 1351 | return EstimatedTxResult( |
| 1352 | utxos: utxoDetails.utxos, |
| 1353 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1354 | publicKeys: utxoDetails.publicKeys, |
| 1355 | fee: Money.fromInt(finalFee, currency), |
| 1356 | amount: amount, |
| 1357 | hasChange: false, |
| 1358 | isSendAll: spendingAllCoins, |
| 1359 | memo: memo, |
| 1360 | spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, |
| 1361 | spendsSilentPayment: utxoDetails.spendsSilentPayment, |
| 1362 | ); |
| 1363 | } |
| 1364 | |
| 1365 | if (!spendingAllCoins) { |
| 1366 | return estimateTxForAmount( |
| 1367 | credentialsAmount, |
| 1368 | outputs, |
| 1369 | updatedOutputs, |
| 1370 | feeRate, |
| 1371 | inputsCount: utxoDetails.utxos.length + 1, |
| 1372 | memo: memo, |
| 1373 | useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, |
| 1374 | hasSilentPayment: hasSilentPayment, |
| 1375 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1376 | ); |
| 1377 | } else { |
| 1378 | throw BitcoinTransactionWrongBalanceException(); |
| 1379 | } |
| 1380 | } |
| 1381 | |
| 1382 | // if the amount left for change is less than dust, but not less than 0 |
| 1383 | // then add it to the fees |
| 1384 | fee += amountLeftForChange.toInt(); |
| 1385 | |
| 1386 | return EstimatedTxResult( |
| 1387 | utxos: utxoDetails.utxos, |
| 1388 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1389 | publicKeys: utxoDetails.publicKeys, |
| 1390 | fee: Money.fromInt(fee, currency), |
| 1391 | amount: amount, |
| 1392 | hasChange: false, |
| 1393 | isSendAll: spendingAllCoins, |
| 1394 | memo: memo, |
| 1395 | spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, |
| 1396 | spendsSilentPayment: utxoDetails.spendsSilentPayment, |
| 1397 | ); |
| 1398 | } else { |
| 1399 | // Here, lastOutput already is change, return the amount left without the fee to the user's address. |
| 1400 | updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( |
| 1401 | address: lastOutput.address, |
| 1402 | value: amountLeftForChange, |
| 1403 | isSilentPayment: lastOutput.isSilentPayment, |
| 1404 | isChange: true, |
| 1405 | ); |
| 1406 | outputs[outputs.length - 1] = BitcoinOutput( |
| 1407 | address: lastOutput.address, |
| 1408 | value: amountLeftForChange, |
| 1409 | isSilentPayment: lastOutput.isSilentPayment, |
| 1410 | isChange: true, |
| 1411 | ); |
| 1412 | |
| 1413 | return EstimatedTxResult( |
| 1414 | utxos: utxoDetails.utxos, |
| 1415 | inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, |
| 1416 | publicKeys: utxoDetails.publicKeys, |
| 1417 | fee: Money.fromInt(fee, currency), |
| 1418 | amount: amount, |
| 1419 | hasChange: true, |
| 1420 | isSendAll: spendingAllCoins, |
| 1421 | memo: memo, |
| 1422 | spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, |
| 1423 | spendsSilentPayment: utxoDetails.spendsSilentPayment, |
| 1424 | ); |
| 1425 | } |
| 1426 | } |
| 1427 | |
| 1428 | Future<Money> _maxSpendableNoChangeAmount({ |
| 1429 | required BitcoinOutput initialOutput, |
| 1430 | required int feeRate, |
| 1431 | String? memo, |
| 1432 | bool hasSilentPayment = false, |
| 1433 | UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, |
| 1434 | }) async { |
| 1435 | final utxoDetailsAll = _createUTXOS( |
| 1436 | sendAll: true, |
| 1437 | paysToSilentPayment: hasSilentPayment, |
| 1438 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1439 | ); |
| 1440 | |
| 1441 | final output = [ |
| 1442 | BitcoinOutput( |
| 1443 | address: initialOutput.address, |
| 1444 | value: BigInt.zero, |
| 1445 | isSilentPayment: initialOutput.isSilentPayment, |
| 1446 | ) |
| 1447 | ]; |
| 1448 | |
| 1449 | final feeNoChange = await calcFee( |
| 1450 | utxos: utxoDetailsAll.utxos, |
| 1451 | outputs: output, |
| 1452 | network: network, |
| 1453 | memo: memo, |
| 1454 | feeRate: feeRate, |
| 1455 | inputPrivKeyInfos: utxoDetailsAll.inputPrivKeyInfos, |
| 1456 | vinOutpoints: utxoDetailsAll.vinOutpoints, |
| 1457 | ); |
| 1458 | |
| 1459 | final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange; |
| 1460 | return Money.fromInt(maxSpendable > 0 ? maxSpendable : 0, currency); |
| 1461 | } |
| 1462 | |
| 1463 | Future<int> calcFee({ |
| 1464 | required List<UtxoWithAddress> utxos, |
| 1465 | required List<BitcoinBaseOutput> outputs, |
| 1466 | required BasedUtxoNetwork network, |
| 1467 | String? memo, |
| 1468 | required int feeRate, |
| 1469 | List<ECPrivateInfo>? inputPrivKeyInfos, |
| 1470 | List<Outpoint>? vinOutpoints, |
| 1471 | }) async { |
| 1472 | int estimatedSize; |
| 1473 | if (network is BitcoinCashNetwork) { |
| 1474 | estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( |
| 1475 | utxos: utxos, |
| 1476 | outputs: outputs, |
| 1477 | network: network, |
| 1478 | memo: memo, |
| 1479 | ); |
| 1480 | } else { |
| 1481 | estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( |
| 1482 | utxos: utxos, |
| 1483 | outputs: outputs, |
| 1484 | network: network, |
| 1485 | memo: memo, |
| 1486 | inputPrivKeyInfos: inputPrivKeyInfos, |
| 1487 | vinOutpoints: vinOutpoints, |
| 1488 | ); |
| 1489 | } |
| 1490 | |
| 1491 | return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize); |
| 1492 | } |
| 1493 | |
| 1494 | @override |
| 1495 | Future<PendingTransaction> createTransaction(Object credentials) async { |
| 1496 | try { |
| 1497 | // New transaction, new random draw: drop the previous input ordering so this |
| 1498 | // build gets fresh priorities, then keep them fixed for all estimate passes. |
| 1499 | _coinSelectionOrder.clear(); |
| 1500 | |
| 1501 | // start by updating unspent coins |
| 1502 | await updateAllUnspents(); |
| 1503 | |
| 1504 | final outputs = <BitcoinOutput>[]; |
| 1505 | final transactionCredentials = credentials as BitcoinTransactionCredentials; |
| 1506 | final hasMultiDestination = transactionCredentials.outputs.length > 1; |
| 1507 | final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; |
| 1508 | final memo = transactionCredentials.outputs.first.memo; |
| 1509 | final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; |
| 1510 | |
| 1511 | var credentialsAmount = Money.zero(currency); |
| 1512 | var hasSilentPayment = false; |
| 1513 | |
| 1514 | for (final out in transactionCredentials.outputs) { |
| 1515 | final outputAmount = out.cryptoAmount; |
| 1516 | |
| 1517 | if (!sendAll && _isBelowDust(outputAmount.amount)) { |
| 1518 | throw BitcoinTransactionNoDustException(); |
| 1519 | } |
| 1520 | |
| 1521 | if (hasMultiDestination) { |
| 1522 | if (out.sendAll) { |
| 1523 | throw BitcoinTransactionWrongBalanceException(); |
| 1524 | } |
| 1525 | } |
| 1526 | |
| 1527 | credentialsAmount += outputAmount; |
| 1528 | |
| 1529 | final address = RegexUtils.addressTypeFromStr( |
| 1530 | out.isParsedAddress ? out.extractedAddress! : out.address, network); |
| 1531 | final isSilentPayment = address is SilentPaymentAddress; |
| 1532 | |
| 1533 | if (isSilentPayment) { |
| 1534 | hasSilentPayment = true; |
| 1535 | } |
| 1536 | |
| 1537 | if (sendAll) { |
| 1538 | // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent |
| 1539 | outputs.add(BitcoinOutput( |
| 1540 | address: address, |
| 1541 | value: BigInt.zero, |
| 1542 | isSilentPayment: isSilentPayment, |
| 1543 | )); |
| 1544 | } else { |
| 1545 | outputs.add(BitcoinOutput( |
| 1546 | address: address, |
| 1547 | value: outputAmount.amount, |
| 1548 | isSilentPayment: isSilentPayment, |
| 1549 | )); |
| 1550 | } |
| 1551 | } |
| 1552 | |
| 1553 | final feeRateInt = transactionCredentials.feeRate != null |
| 1554 | ? transactionCredentials.feeRate! |
| 1555 | : feeRate(transactionCredentials.priority!); |
| 1556 | |
| 1557 | EstimatedTxResult estimatedTx; |
| 1558 | final updatedOutputs = outputs |
| 1559 | .map((e) => BitcoinOutput( |
| 1560 | address: e.address, |
| 1561 | value: e.value, |
| 1562 | isSilentPayment: e.isSilentPayment, |
| 1563 | isChange: e.isChange, |
| 1564 | )) |
| 1565 | .toList(); |
| 1566 | |
| 1567 | if (sendAll) { |
| 1568 | estimatedTx = await estimateSendAllTx( |
| 1569 | updatedOutputs, |
| 1570 | feeRateInt, |
| 1571 | memo: memo, |
| 1572 | hasSilentPayment: hasSilentPayment, |
| 1573 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1574 | ); |
| 1575 | } else { |
| 1576 | estimatedTx = await estimateTxForAmount( |
| 1577 | credentialsAmount, |
| 1578 | outputs, |
| 1579 | updatedOutputs, |
| 1580 | feeRateInt, |
| 1581 | memo: memo, |
| 1582 | hasSilentPayment: hasSilentPayment, |
| 1583 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1584 | ); |
| 1585 | } |
| 1586 | |
| 1587 | for (final output in updatedOutputs) { |
| 1588 | // TODO: get from server |
| 1589 | // if (output.isSilentPayment && output.value.toInt() > silentPaymentsMin) { |
| 1590 | if (output.isSilentPayment && output.value.toInt() <= 1000) { |
| 1591 | throw BitcoinTransactionNoDustException(); |
| 1592 | } |
| 1593 | } |
| 1594 | |
| 1595 | if (walletInfo.isHardwareWallet) { |
| 1596 | final transaction = await buildHardwareWalletTransaction( |
| 1597 | utxos: estimatedTx.utxos, |
| 1598 | outputs: updatedOutputs, |
| 1599 | publicKeys: estimatedTx.publicKeys, |
| 1600 | fee: estimatedTx.fee.amount, |
| 1601 | network: network, |
| 1602 | memo: estimatedTx.memo, |
| 1603 | // Shuffle so the change output isn't placed deterministically last |
| 1604 | // (privacy fingerprint). Applied by orderOutputs in the builder. |
| 1605 | outputOrdering: BitcoinOrdering.shuffle, |
| 1606 | enableRBF: true, |
| 1607 | cwOutputs: transactionCredentials.outputs, |
| 1608 | ); |
| 1609 | |
| 1610 | return PendingBitcoinTransaction( |
| 1611 | transaction, |
| 1612 | type, |
| 1613 | electrumClient: electrumClient, |
| 1614 | amount: estimatedTx.amount, |
| 1615 | fee: estimatedTx.fee, |
| 1616 | feeRate: feeRateInt.toString(), |
| 1617 | network: network, |
| 1618 | hasChange: estimatedTx.hasChange, |
| 1619 | isSendAll: estimatedTx.isSendAll, |
| 1620 | hasTaprootInputs: false, |
| 1621 | // ToDo: (Konsti) Support Taproot, |
| 1622 | isViewOnly: false, |
| 1623 | )..addListener((transaction) async { |
| 1624 | transactionHistory.addOne(transaction); |
| 1625 | await updateBalance(); |
| 1626 | await updateAllUnspents(); |
| 1627 | }); |
| 1628 | } |
| 1629 | |
| 1630 | final locktime = await _antiFeeSnipingLocktime(); |
| 1631 | |
| 1632 | BasedBitcoinTransacationBuilder txb; |
| 1633 | if (network is BitcoinCashNetwork) { |
| 1634 | txb = ForkedTransactionBuilder( |
| 1635 | utxos: estimatedTx.utxos, |
| 1636 | outputs: updatedOutputs, |
| 1637 | fee: estimatedTx.fee.amount, |
| 1638 | network: network, |
| 1639 | memo: estimatedTx.memo, |
| 1640 | // Shuffle so the change output isn't placed deterministically last |
| 1641 | // (privacy fingerprint). Change is found by isChange, not position. |
| 1642 | inputOrdering: BitcoinOrdering.shuffle, |
| 1643 | outputOrdering: BitcoinOrdering.shuffle, |
| 1644 | enableRBF: !estimatedTx.spendsUnconfirmedTX, |
| 1645 | ); |
| 1646 | } else { |
| 1647 | txb = BitcoinTransactionBuilder( |
| 1648 | utxos: estimatedTx.utxos, |
| 1649 | outputs: updatedOutputs, |
| 1650 | fee: estimatedTx.fee.amount, |
| 1651 | network: network, |
| 1652 | memo: estimatedTx.memo, |
| 1653 | inputOrdering: BitcoinOrdering.shuffle, |
| 1654 | outputOrdering: BitcoinOrdering.shuffle, |
| 1655 | enableRBF: !estimatedTx.spendsUnconfirmedTX, |
| 1656 | locktime: locktime, |
| 1657 | ); |
| 1658 | } |
| 1659 | |
| 1660 | bool hasTaprootInputs = false; |
| 1661 | |
| 1662 | final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { |
| 1663 | if (keys.privateKey.isEmpty) return ""; |
| 1664 | String error = "Cannot find private key."; |
| 1665 | |
| 1666 | ECPrivateInfo? key; |
| 1667 | |
| 1668 | if (estimatedTx.inputPrivKeyInfos.isEmpty) { |
| 1669 | error += "\nNo private keys generated."; |
| 1670 | } else { |
| 1671 | error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}"; |
| 1672 | |
| 1673 | key = estimatedTx.inputPrivKeyInfos.firstWhereOrNull((element) { |
| 1674 | final elemPubkey = element.privkey.getPublic().toHex(); |
| 1675 | if (elemPubkey == publicKey) { |
| 1676 | return true; |
| 1677 | } else { |
| 1678 | error += "\nExpected: $publicKey"; |
| 1679 | error += "\nPubkey: $elemPubkey"; |
| 1680 | return false; |
| 1681 | } |
| 1682 | }); |
| 1683 | } |
| 1684 | |
| 1685 | if (key == null) { |
| 1686 | throw Exception(error); |
| 1687 | } |
| 1688 | |
| 1689 | if (utxo.utxo.isP2tr()) { |
| 1690 | hasTaprootInputs = true; |
| 1691 | return key.privkey.signTapRoot( |
| 1692 | txDigest, |
| 1693 | sighash: sighash, |
| 1694 | tweak: utxo.utxo.isSilentPayment != true, |
| 1695 | ); |
| 1696 | } else { |
| 1697 | return key.privkey.signInput(txDigest, sigHash: sighash); |
| 1698 | } |
| 1699 | }); |
| 1700 | |
| 1701 | return PendingBitcoinTransaction( |
| 1702 | transaction, |
| 1703 | type, |
| 1704 | electrumClient: electrumClient, |
| 1705 | amount: estimatedTx.amount, |
| 1706 | fee: estimatedTx.fee, |
| 1707 | feeRate: feeRateInt.toString(), |
| 1708 | network: network, |
| 1709 | hasChange: estimatedTx.hasChange, |
| 1710 | isSendAll: estimatedTx.isSendAll, |
| 1711 | hasTaprootInputs: hasTaprootInputs, |
| 1712 | utxos: estimatedTx.utxos, |
| 1713 | derivedOutputs: updatedOutputs, |
| 1714 | publicKeys: estimatedTx.publicKeys, |
| 1715 | isViewOnly: keys.privateKey.isEmpty, |
| 1716 | )..addListener((transaction) async { |
| 1717 | transactionHistory.addOne(transaction); |
| 1718 | if (estimatedTx.spendsSilentPayment) { |
| 1719 | transactionHistory.transactions.values.forEach((tx) { |
| 1720 | tx.unspents?.removeWhere( |
| 1721 | (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); |
| 1722 | transactionHistory.addOne(tx); |
| 1723 | }); |
| 1724 | } |
| 1725 | |
| 1726 | unspentCoins |
| 1727 | .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); |
| 1728 | |
| 1729 | await updateBalance(); |
| 1730 | await updateAllUnspents(); |
| 1731 | }); |
| 1732 | } catch (e) { |
| 1733 | throw e; |
| 1734 | } |
| 1735 | } |
| 1736 | |
| 1737 | HardwareWalletService? hardwareWalletService; |
| 1738 | |
| 1739 | Future<BtcTransaction> buildHardwareWalletTransaction({ |
| 1740 | required List<BitcoinBaseOutput> outputs, |
| 1741 | required BigInt fee, |
| 1742 | required BasedUtxoNetwork network, |
| 1743 | required List<UtxoWithAddress> utxos, |
| 1744 | required List<OutputInfo> cwOutputs, |
| 1745 | required Map<String, PublicKeyWithDerivationPath> publicKeys, |
| 1746 | String? memo, |
| 1747 | bool enableRBF = false, |
| 1748 | BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, |
| 1749 | BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, |
| 1750 | }) async => |
| 1751 | throw UnimplementedError(); |
| 1752 | |
| 1753 | String toJSON() => json.encode({ |
| 1754 | 'mnemonic': _mnemonic, |
| 1755 | 'xpub': xpub, |
| 1756 | 'passphrase': passphrase ?? '', |
| 1757 | 'account_index': walletAddresses.currentReceiveAddressIndexByType, |
| 1758 | 'change_address_index': walletAddresses.currentChangeAddressIndexByType, |
| 1759 | 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), |
| 1760 | 'address_page_type': walletInfo.addressPageType == null |
| 1761 | ? SegwitAddresType.p2wpkh.toString() |
| 1762 | : walletInfo.addressPageType.toString(), |
| 1763 | 'balance': balance[currency]?.toJSON(), |
| 1764 | 'lightningBalance': balance[CryptoCurrency.btcln]?.toJSON(), |
| 1765 | 'derivationTypeIndex': derivationInfo.derivationType?.index, |
| 1766 | 'derivationPath': derivationInfo.derivationPath, |
| 1767 | 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), |
| 1768 | 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), |
| 1769 | 'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(), |
| 1770 | 'alwaysScan': alwaysScan, |
| 1771 | 'useLightning': useLightning, |
| 1772 | 'cachedLightningAddress': walletAddresses.lightningAddress |
| 1773 | }); |
| 1774 | |
| 1775 | int feeRate(TransactionPriority priority) { |
| 1776 | try { |
| 1777 | if (priority is BitcoinTransactionPriority) { |
| 1778 | return _feeRates[priority.raw]; |
| 1779 | } |
| 1780 | |
| 1781 | return 0; |
| 1782 | } catch (_) { |
| 1783 | return 0; |
| 1784 | } |
| 1785 | } |
| 1786 | |
| 1787 | int feeAmountForPriority(TransactionPriority priority, int inputsCount, int outputsCount, |
| 1788 | {int? size}) => |
| 1789 | feeRate(priority) * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); |
| 1790 | |
| 1791 | int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) => |
| 1792 | feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); |
| 1793 | |
| 1794 | @override |
| 1795 | int calculateEstimatedFee(TransactionPriority? priority, int? amount, |
| 1796 | {int? outputsCount, int? size}) { |
| 1797 | if (priority is BitcoinTransactionPriority) { |
| 1798 | return calculateEstimatedFeeWithFeeRate(feeRate(priority), amount, |
| 1799 | outputsCount: outputsCount, size: size); |
| 1800 | } |
| 1801 | |
| 1802 | return 0; |
| 1803 | } |
| 1804 | |
| 1805 | int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { |
| 1806 | if (size != null) { |
| 1807 | return feeAmountWithFeeRate(feeRate, 0, 0, size: size); |
| 1808 | } |
| 1809 | |
| 1810 | int inputsCount = 0; |
| 1811 | |
| 1812 | if (amount != null) { |
| 1813 | int totalValue = 0; |
| 1814 | |
| 1815 | for (final input in unspentCoins) { |
| 1816 | if (totalValue >= amount) { |
| 1817 | break; |
| 1818 | } |
| 1819 | |
| 1820 | if (input.isSending) { |
| 1821 | totalValue += input.value; |
| 1822 | inputsCount += 1; |
| 1823 | } |
| 1824 | } |
| 1825 | |
| 1826 | if (totalValue < amount) return 0; |
| 1827 | } else { |
| 1828 | for (final input in unspentCoins) { |
| 1829 | if (input.isSending) { |
| 1830 | inputsCount += 1; |
| 1831 | } |
| 1832 | } |
| 1833 | } |
| 1834 | |
| 1835 | // If send all, then we have no change value |
| 1836 | final _outputsCount = outputsCount ?? (amount != null ? 2 : 1); |
| 1837 | |
| 1838 | return feeAmountWithFeeRate(feeRate, inputsCount, _outputsCount); |
| 1839 | } |
| 1840 | |
| 1841 | @override |
| 1842 | Future<void> save() async { |
| 1843 | if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) { |
| 1844 | await saveKeysFile(_password, encryptionFileUtils); |
| 1845 | saveKeysFile(_password, encryptionFileUtils, true); |
| 1846 | } |
| 1847 | |
| 1848 | final path = await makePath(); |
| 1849 | await encryptionFileUtils.write(path: path, password: _password, data: toJSON()); |
| 1850 | await transactionHistory.save(); |
| 1851 | } |
| 1852 | |
| 1853 | @override |
| 1854 | Future<void> changePassword(String password) async { |
| 1855 | _password = password; |
| 1856 | await save(); |
| 1857 | await transactionHistory.changePassword(password); |
| 1858 | } |
| 1859 | |
| 1860 | @action |
| 1861 | @override |
| 1862 | Future<void> rescan({required int height, bool? doSingleScan}) async { |
| 1863 | if (keys.privateKey.isEmpty) return; |
| 1864 | |
| 1865 | silentPaymentsScanningActive = true; |
| 1866 | _setListeners(height, doSingleScan: doSingleScan); |
| 1867 | } |
| 1868 | |
| 1869 | @override |
| 1870 | Future<void> close({bool shouldCleanup = false}) async { |
| 1871 | try { |
| 1872 | await _receiveStream?.cancel(); |
| 1873 | await electrumClient.close(); |
| 1874 | _isBatchSupported = null; |
| 1875 | } catch (_) {} |
| 1876 | _autoSaveTimer?.cancel(); |
| 1877 | _updateFeeRateTimer?.cancel(); |
| 1878 | } |
| 1879 | |
| 1880 | @action |
| 1881 | Future<void> updateAllUnspents() async { |
| 1882 | List<BitcoinUnspent> updatedUnspentCoins = []; |
| 1883 | |
| 1884 | final previousUnspentCoins = List<BitcoinUnspent>.from(unspentCoins.where((utxo) => |
| 1885 | utxo.bitcoinAddressRecord.type != SegwitAddresType.mweb && |
| 1886 | utxo.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)); |
| 1887 | |
| 1888 | if (hasSilentPaymentsScanning) { |
| 1889 | // Update unspents stored from scanned silent payment transactions |
| 1890 | transactionHistory.transactions.values.forEach((tx) { |
| 1891 | if (tx.unspents != null) { |
| 1892 | updatedUnspentCoins.addAll(tx.unspents!); |
| 1893 | } |
| 1894 | }); |
| 1895 | } |
| 1896 | |
| 1897 | // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating |
| 1898 | |
| 1899 | final targetAddresses = walletAddresses.allAddresses |
| 1900 | .where((element) => element.type != SegwitAddresType.mweb) |
| 1901 | .toList(); |
| 1902 | |
| 1903 | for (final addr in targetAddresses) { |
| 1904 | if (addr is! BitcoinSilentPaymentAddressRecord) { |
| 1905 | addr.balance = 0; |
| 1906 | } |
| 1907 | } |
| 1908 | |
| 1909 | final results = shouldUseBatchFetching |
| 1910 | ? await _fetchUnspentsBatch(targetAddresses) |
| 1911 | : await _fetchUnspentsRegular(targetAddresses); |
| 1912 | |
| 1913 | final failedCount = results.where((result) => result == null).length; |
| 1914 | |
| 1915 | if (failedCount == 0) { |
| 1916 | for (final result in results) { |
| 1917 | updatedUnspentCoins.addAll(result!); |
| 1918 | } |
| 1919 | unspentCoins = updatedUnspentCoins; |
| 1920 | } else { |
| 1921 | if (updatedUnspentCoins.isEmpty) { |
| 1922 | unspentCoins = handleFailedUtxoFetch( |
| 1923 | failedCount: failedCount, |
| 1924 | previousUnspentCoins: previousUnspentCoins, |
| 1925 | updatedUnspentCoins: updatedUnspentCoins, |
| 1926 | results: results, |
| 1927 | ); |
| 1928 | } else { |
| 1929 | unspentCoins = updatedUnspentCoins; |
| 1930 | } |
| 1931 | } |
| 1932 | |
| 1933 | final currentWalletUnspentCoins = |
| 1934 | unspentCoinsInfo.values.where((element) => element.walletId == id); |
| 1935 | |
| 1936 | if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) { |
| 1937 | unspentCoins.forEach((coin) => addCoinInfo(coin)); |
| 1938 | } |
| 1939 | |
| 1940 | await updateCoins(unspentCoins); |
| 1941 | await _refreshUnspentCoinsInfo(); |
| 1942 | } |
| 1943 | |
| 1944 | Future<List<List<BitcoinUnspent>?>> _fetchUnspentsRegular( |
| 1945 | List<BitcoinAddressRecord> addresses, |
| 1946 | ) async { |
| 1947 | final addressFutures = addresses.map((address) => fetchUnspent(address)).toList(); |
| 1948 | return Future.wait(addressFutures); |
| 1949 | } |
| 1950 | |
| 1951 | Future<List<List<BitcoinUnspent>?>> _fetchUnspentsBatch( |
| 1952 | List<BitcoinAddressRecord> addresses, |
| 1953 | ) async { |
| 1954 | final byScriptHash = <String, BitcoinAddressRecord>{ |
| 1955 | for (final address in addresses) address.getScriptHash(network): address, |
| 1956 | }; |
| 1957 | |
| 1958 | final scriptHashes = byScriptHash.keys.toList(); |
| 1959 | |
| 1960 | try { |
| 1961 | final unspentByScriptHash = |
| 1962 | await _processChunksToMap<String, String, List<Map<String, dynamic>>>( |
| 1963 | items: scriptHashes, |
| 1964 | chunkSize: addressHistoryChunkSize, |
| 1965 | processChunk: _getListUnspentBatch, |
| 1966 | ); |
| 1967 | |
| 1968 | final txHashes = <String>{}; |
| 1969 | final coinsByScriptHash = <String, List<BitcoinUnspent>>{}; |
| 1970 | |
| 1971 | for (final entry in unspentByScriptHash.entries) { |
| 1972 | final addressRecord = byScriptHash[entry.key]; |
| 1973 | if (addressRecord == null) continue; |
| 1974 | |
| 1975 | final coins = <BitcoinUnspent>[]; |
| 1976 | |
| 1977 | for (final unspent in entry.value) { |
| 1978 | final coin = BitcoinUnspent.fromJSON(addressRecord, unspent); |
| 1979 | coin.isChange = addressRecord.isHidden; |
| 1980 | coins.add(coin); |
| 1981 | txHashes.add(coin.hash); |
| 1982 | } |
| 1983 | |
| 1984 | coinsByScriptHash[entry.key] = coins; |
| 1985 | } |
| 1986 | |
| 1987 | final txInfoByHash = await fetchTransactionInfoBatch( |
| 1988 | hashes: txHashes.toList(), |
| 1989 | retryOnFailure: true, |
| 1990 | retryDelay: const Duration(seconds: 1), |
| 1991 | ); |
| 1992 | |
| 1993 | for (final coins in coinsByScriptHash.values) { |
| 1994 | for (final coin in coins) { |
| 1995 | final tx = txInfoByHash[coin.hash]; |
| 1996 | coin.confirmations = tx?.confirmations; |
| 1997 | coin.isPegOut = tx?.isHogEx; |
| 1998 | } |
| 1999 | } |
| 2000 | |
| 2001 | return addresses.map((address) { |
| 2002 | final scriptHash = address.getScriptHash(network); |
| 2003 | return coinsByScriptHash[scriptHash] ?? <BitcoinUnspent>[]; |
| 2004 | }).toList(); |
| 2005 | } catch (e) { |
| 2006 | printV('fetchUnspentsBatch failed: $e'); |
| 2007 | return List<List<BitcoinUnspent>?>.filled(addresses.length, null); |
| 2008 | } |
| 2009 | } |
| 2010 | |
| 2011 | List<BitcoinUnspent> handleFailedUtxoFetch({ |
| 2012 | required int failedCount, |
| 2013 | required List<BitcoinUnspent> previousUnspentCoins, |
| 2014 | required List<BitcoinUnspent> updatedUnspentCoins, |
| 2015 | required List<List<BitcoinUnspent>?> results, |
| 2016 | }) { |
| 2017 | if (failedCount == results.length) { |
| 2018 | printV("All UTXOs failed to fetch, falling back to previous UTXOs"); |
| 2019 | return previousUnspentCoins; |
| 2020 | } |
| 2021 | |
| 2022 | final successfulUtxos = <BitcoinUnspent>[]; |
| 2023 | for (final result in results) { |
| 2024 | if (result != null) { |
| 2025 | successfulUtxos.addAll(result); |
| 2026 | } |
| 2027 | } |
| 2028 | |
| 2029 | if (failedCount > 0 && successfulUtxos.isEmpty) { |
| 2030 | printV("Some UTXOs failed, but no successful UTXOs, falling back to previous UTXOs"); |
| 2031 | return previousUnspentCoins; |
| 2032 | } |
| 2033 | |
| 2034 | if (failedCount > 0) { |
| 2035 | printV("Some UTXOs failed, updating with successful UTXOs"); |
| 2036 | updatedUnspentCoins.addAll(successfulUtxos); |
| 2037 | } |
| 2038 | |
| 2039 | return updatedUnspentCoins; |
| 2040 | } |
| 2041 | |
| 2042 | Future<void> updateCoins(List<BitcoinUnspent> newUnspentCoins) async { |
| 2043 | if (newUnspentCoins.isEmpty) { |
| 2044 | return; |
| 2045 | } |
| 2046 | |
| 2047 | newUnspentCoins.forEach((coin) { |
| 2048 | final coinInfoList = unspentCoinsInfo.values.where( |
| 2049 | (element) => |
| 2050 | element.walletId.contains(id) && |
| 2051 | element.hash.contains(coin.hash) && |
| 2052 | element.vout == coin.vout, |
| 2053 | ); |
| 2054 | |
| 2055 | if (coinInfoList.isNotEmpty) { |
| 2056 | final coinInfo = coinInfoList.first; |
| 2057 | |
| 2058 | coin.isFrozen = coinInfo.isFrozen; |
| 2059 | coin.isSending = coinInfo.isSending; |
| 2060 | coin.note = coinInfo.note; |
| 2061 | |
| 2062 | if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) |
| 2063 | coin.bitcoinAddressRecord.balance += coinInfo.value; |
| 2064 | } else { |
| 2065 | addCoinInfo(coin); |
| 2066 | } |
| 2067 | }); |
| 2068 | } |
| 2069 | |
| 2070 | @action |
| 2071 | Future<void> updateUnspentsForAddress(BitcoinAddressRecord address) async { |
| 2072 | final newUnspentCoins = await fetchUnspent(address); |
| 2073 | await updateCoins(newUnspentCoins ?? []); |
| 2074 | } |
| 2075 | |
| 2076 | @action |
| 2077 | Future<List<BitcoinUnspent>?> fetchUnspent(BitcoinAddressRecord address) async { |
| 2078 | List<BitcoinUnspent> updatedUnspentCoins = []; |
| 2079 | |
| 2080 | final unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); |
| 2081 | |
| 2082 | // Failed to fetch unspents |
| 2083 | if (unspents == null) return null; |
| 2084 | |
| 2085 | await Future.wait(unspents.map((unspent) async { |
| 2086 | try { |
| 2087 | final coin = BitcoinUnspent.fromJSON(address, unspent); |
| 2088 | final tx = await fetchTransactionInfo(hash: coin.hash); |
| 2089 | coin.isChange = address.isHidden; |
| 2090 | coin.confirmations = tx?.confirmations; |
| 2091 | coin.isPegOut = tx?.isHogEx; |
| 2092 | |
| 2093 | updatedUnspentCoins.add(coin); |
| 2094 | } catch (_) {} |
| 2095 | })); |
| 2096 | |
| 2097 | return updatedUnspentCoins; |
| 2098 | } |
| 2099 | |
| 2100 | @action |
| 2101 | Future<void> addCoinInfo(BitcoinUnspent coin) async { |
| 2102 | // Check if the coin is already in the unspentCoinsInfo for the wallet |
| 2103 | final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull( |
| 2104 | (element) => |
| 2105 | element.walletId == walletInfo.id && |
| 2106 | element.hash == coin.hash && |
| 2107 | element.vout == coin.vout, |
| 2108 | ); |
| 2109 | |
| 2110 | if (existingCoinInfo == null) { |
| 2111 | final newInfo = UnspentCoinsInfo( |
| 2112 | walletId: id, |
| 2113 | hash: coin.hash, |
| 2114 | isFrozen: coin.isFrozen, |
| 2115 | isSending: coin.isSending, |
| 2116 | noteRaw: coin.note, |
| 2117 | address: coin.bitcoinAddressRecord.address, |
| 2118 | value: coin.value, |
| 2119 | vout: coin.vout, |
| 2120 | isChange: coin.isChange, |
| 2121 | isSilentPayment: coin is BitcoinSilentPaymentsUnspent, |
| 2122 | ); |
| 2123 | |
| 2124 | await unspentCoinsInfo.add(newInfo); |
| 2125 | } |
| 2126 | } |
| 2127 | |
| 2128 | Future<void> _refreshUnspentCoinsInfo() async { |
| 2129 | try { |
| 2130 | final List<dynamic> keys = []; |
| 2131 | final currentWalletUnspentCoins = |
| 2132 | unspentCoinsInfo.values.where((record) => record.walletId == id); |
| 2133 | |
| 2134 | for (final element in currentWalletUnspentCoins) { |
| 2135 | if (element.isFrozen) continue; |
| 2136 | if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; |
| 2137 | |
| 2138 | final existUnspentCoins = unspentCoins.where((coin) => element == coin); |
| 2139 | |
| 2140 | if (existUnspentCoins.isEmpty) { |
| 2141 | keys.add(element.key); |
| 2142 | } |
| 2143 | } |
| 2144 | |
| 2145 | if (keys.isNotEmpty) { |
| 2146 | await unspentCoinsInfo.deleteAll(keys); |
| 2147 | } |
| 2148 | } catch (e) { |
| 2149 | printV("refreshUnspentCoinsInfo $e"); |
| 2150 | } |
| 2151 | } |
| 2152 | |
| 2153 | Future<void> cleanUpDuplicateUnspentCoins() async { |
| 2154 | final currentWalletUnspentCoins = |
| 2155 | unspentCoinsInfo.values.where((element) => element.walletId == id); |
| 2156 | final Map<String, UnspentCoinsInfo> uniqueUnspentCoins = {}; |
| 2157 | final List<dynamic> duplicateKeys = []; |
| 2158 | |
| 2159 | for (final unspentCoin in currentWalletUnspentCoins) { |
| 2160 | final key = '${unspentCoin.hash}:${unspentCoin.vout}'; |
| 2161 | if (!uniqueUnspentCoins.containsKey(key)) { |
| 2162 | uniqueUnspentCoins[key] = unspentCoin; |
| 2163 | } else { |
| 2164 | duplicateKeys.add(unspentCoin.key); |
| 2165 | } |
| 2166 | } |
| 2167 | |
| 2168 | if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); |
| 2169 | } |
| 2170 | |
| 2171 | int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); |
| 2172 | |
| 2173 | Future<String?> canReplaceByFee(ElectrumTransactionInfo tx) async { |
| 2174 | try { |
| 2175 | final bundle = await getTransactionExpanded(hash: tx.txHash); |
| 2176 | _updateInputsAndOutputs(tx, bundle); |
| 2177 | if (bundle.confirmations > 0) return null; |
| 2178 | return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null; |
| 2179 | } catch (e) { |
| 2180 | return null; |
| 2181 | } |
| 2182 | } |
| 2183 | |
| 2184 | Future<bool> isChangeSufficientForFee(String txId, int newFee) async { |
| 2185 | final bundle = await getTransactionExpanded(hash: txId); |
| 2186 | final outputs = bundle.originalTransaction.outputs; |
| 2187 | |
| 2188 | final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); |
| 2189 | |
| 2190 | final receiverAmount = outputs |
| 2191 | .where((output) => |
| 2192 | !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network))) |
| 2193 | .fold<int>(0, (sum, output) => sum + output.amount.toInt()); |
| 2194 | |
| 2195 | if (receiverAmount == 0) { |
| 2196 | throw Exception("Receiver output not found."); |
| 2197 | } |
| 2198 | |
| 2199 | final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); |
| 2200 | int totalBalance = availableInputs.fold<int>( |
| 2201 | 0, (previousValue, element) => previousValue + element.value.toInt()); |
| 2202 | |
| 2203 | int allInputsAmount = 0; |
| 2204 | for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { |
| 2205 | final input = bundle.originalTransaction.inputs[i]; |
| 2206 | final inputTransaction = bundle.ins[i]; |
| 2207 | if (inputTransaction == null) { |
| 2208 | throw Exception("Missing input transaction for fee calculation"); |
| 2209 | } |
| 2210 | final vout = input.txIndex; |
| 2211 | final outTransaction = inputTransaction.outputs[vout]; |
| 2212 | allInputsAmount += outTransaction.amount.toInt(); |
| 2213 | } |
| 2214 | |
| 2215 | final totalOutAmount = bundle.originalTransaction.outputs |
| 2216 | .fold<int>(0, (previousValue, element) => previousValue + element.amount.toInt()); |
| 2217 | var currentFee = allInputsAmount - totalOutAmount; |
| 2218 | |
| 2219 | final remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; |
| 2220 | return totalBalance - receiverAmount - remainingFee >= networkDustAmount.toInt(); |
| 2221 | } |
| 2222 | |
| 2223 | Future<PendingBitcoinTransaction> replaceByFee(String hash, int newFee) async { |
| 2224 | try { |
| 2225 | final bundle = await getTransactionExpanded(hash: hash); |
| 2226 | |
| 2227 | final utxos = <UtxoWithAddress>[]; |
| 2228 | final outputs = <BitcoinOutput>[]; |
| 2229 | List<ECPrivate> privateKeys = []; |
| 2230 | |
| 2231 | var allInputsAmount = 0; |
| 2232 | String? memo; |
| 2233 | |
| 2234 | // Add original inputs |
| 2235 | for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { |
| 2236 | final input = bundle.originalTransaction.inputs[i]; |
| 2237 | final inputTransaction = bundle.ins[i]; |
| 2238 | if (inputTransaction == null) { |
| 2239 | throw Exception("Missing input transaction for replace-by-fee"); |
| 2240 | } |
| 2241 | final vout = input.txIndex; |
| 2242 | final outTransaction = inputTransaction.outputs[vout]; |
| 2243 | final address = addressFromOutputScript(outTransaction.scriptPubKey, network); |
| 2244 | allInputsAmount += outTransaction.amount.toInt(); |
| 2245 | |
| 2246 | final addressRecord = |
| 2247 | walletAddresses.allAddresses.firstWhere((element) => element.address == address); |
| 2248 | final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); |
| 2249 | |
| 2250 | final hd = _hdFor(record: addressRecord); |
| 2251 | |
| 2252 | final privkey = generateECPrivate(hd: hd, index: addressRecord.index, network: network); |
| 2253 | |
| 2254 | privateKeys.add(privkey); |
| 2255 | |
| 2256 | utxos.add( |
| 2257 | UtxoWithAddress( |
| 2258 | utxo: BitcoinUtxo( |
| 2259 | txHash: input.txId, |
| 2260 | value: outTransaction.amount, |
| 2261 | vout: vout, |
| 2262 | scriptType: _getScriptType(btcAddress), |
| 2263 | ), |
| 2264 | ownerDetails: |
| 2265 | UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress), |
| 2266 | ), |
| 2267 | ); |
| 2268 | } |
| 2269 | |
| 2270 | // Add original outputs |
| 2271 | for (final out in bundle.originalTransaction.outputs) { |
| 2272 | final script = out.scriptPubKey.script; |
| 2273 | if (script.contains('OP_RETURN') && memo == null) { |
| 2274 | final index = script.indexOf('OP_RETURN'); |
| 2275 | if (index + 1 <= script.length) { |
| 2276 | try { |
| 2277 | final opReturnData = script[index + 1].toString(); |
| 2278 | memo = utf8.decode(HEX.decode(opReturnData)); |
| 2279 | continue; |
| 2280 | } catch (_) { |
| 2281 | throw Exception('Cannot decode OP_RETURN data'); |
| 2282 | } |
| 2283 | } |
| 2284 | } |
| 2285 | |
| 2286 | final address = addressFromOutputScript(out.scriptPubKey, network); |
| 2287 | final btcAddress = RegexUtils.addressTypeFromStr(address, network); |
| 2288 | outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt()))); |
| 2289 | } |
| 2290 | |
| 2291 | // Calculate the total amount and fees |
| 2292 | int totalOutAmount = |
| 2293 | outputs.fold<int>(0, (previousValue, output) => previousValue + output.value.toInt()); |
| 2294 | int currentFee = allInputsAmount - totalOutAmount; |
| 2295 | var remainingFee = BigInt.from(newFee - currentFee); |
| 2296 | |
| 2297 | if (remainingFee <= BigInt.zero) { |
| 2298 | throw Exception("New fee must be higher than the current fee."); |
| 2299 | } |
| 2300 | |
| 2301 | // Deduct fee from change outputs first, if possible |
| 2302 | if (remainingFee > BigInt.zero) { |
| 2303 | final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); |
| 2304 | for (int i = outputs.length - 1; i >= 0; i--) { |
| 2305 | final output = outputs[i]; |
| 2306 | final isChange = changeAddresses |
| 2307 | .any((element) => element.address == output.address.toAddress(network)); |
| 2308 | |
| 2309 | if (isChange) { |
| 2310 | final outputAmount = output.value; |
| 2311 | if (outputAmount > networkDustAmount) { |
| 2312 | final deduction = (outputAmount - networkDustAmount >= remainingFee) |
| 2313 | ? remainingFee |
| 2314 | : outputAmount - networkDustAmount; |
| 2315 | outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); |
| 2316 | remainingFee -= deduction; |
| 2317 | |
| 2318 | if (remainingFee <= BigInt.zero) break; |
| 2319 | } |
| 2320 | } |
| 2321 | } |
| 2322 | } |
| 2323 | |
| 2324 | // If still not enough, add UTXOs until the fee is covered, drawing them at |
| 2325 | // random instead of in the predictable wallet scan order (address, then age). |
| 2326 | if (remainingFee > BigInt.zero) { |
| 2327 | final unusedUtxos = unspentCoins |
| 2328 | .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) |
| 2329 | .toList() |
| 2330 | ..shuffle(Random.secure()); |
| 2331 | |
| 2332 | for (final utxo in unusedUtxos) { |
| 2333 | final address = RegexUtils.addressTypeFromStr(utxo.address, network); |
| 2334 | |
| 2335 | final hd = _hdFor(record: utxo.bitcoinAddressRecord); |
| 2336 | |
| 2337 | final privkey = generateECPrivate( |
| 2338 | hd: hd, |
| 2339 | index: utxo.bitcoinAddressRecord.index, |
| 2340 | network: network, |
| 2341 | ); |
| 2342 | privateKeys.add(privkey); |
| 2343 | |
| 2344 | utxos.add(UtxoWithAddress( |
| 2345 | utxo: BitcoinUtxo( |
| 2346 | txHash: utxo.hash, |
| 2347 | value: BigInt.from(utxo.value), |
| 2348 | vout: utxo.vout, |
| 2349 | scriptType: _getScriptType(address)), |
| 2350 | ownerDetails: |
| 2351 | UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), |
| 2352 | )); |
| 2353 | |
| 2354 | allInputsAmount += utxo.value; |
| 2355 | remainingFee -= BigInt.from(utxo.value); |
| 2356 | |
| 2357 | if (remainingFee < BigInt.zero) { |
| 2358 | final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses |
| 2359 | .any((addr) => addr.address == output.address.toAddress(network))); |
| 2360 | if (changeOutput != null) { |
| 2361 | final newValue = changeOutput.value + (-remainingFee); |
| 2362 | outputs[outputs.indexOf(changeOutput)] = |
| 2363 | BitcoinOutput(address: changeOutput.address, value: newValue); |
| 2364 | } else { |
| 2365 | final changeAddress = await walletAddresses.getChangeAddress(); |
| 2366 | outputs.add(BitcoinOutput( |
| 2367 | address: RegexUtils.addressTypeFromStr(changeAddress.address, network), |
| 2368 | value: -remainingFee)); |
| 2369 | } |
| 2370 | |
| 2371 | remainingFee = BigInt.zero; |
| 2372 | break; |
| 2373 | } |
| 2374 | |
| 2375 | if (remainingFee <= BigInt.zero) break; |
| 2376 | } |
| 2377 | } |
| 2378 | |
| 2379 | // Deduct from the receiver's output if remaining fee is still greater than 0 |
| 2380 | if (remainingFee > BigInt.zero) { |
| 2381 | for (int i = 0; i < outputs.length; i++) { |
| 2382 | final output = outputs[i]; |
| 2383 | final outputAmount = output.value; |
| 2384 | |
| 2385 | if (outputAmount > networkDustAmount) { |
| 2386 | final deduction = (outputAmount - networkDustAmount >= remainingFee) |
| 2387 | ? remainingFee |
| 2388 | : outputAmount - networkDustAmount; |
| 2389 | |
| 2390 | outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); |
| 2391 | remainingFee -= deduction; |
| 2392 | |
| 2393 | if (remainingFee <= BigInt.zero) break; |
| 2394 | } |
| 2395 | } |
| 2396 | } |
| 2397 | |
| 2398 | // Final check if the remaining fee couldn't be deducted |
| 2399 | if (remainingFee > BigInt.zero) { |
| 2400 | throw Exception("Not enough funds to cover the fee."); |
| 2401 | } |
| 2402 | |
| 2403 | // Identify all change outputs |
| 2404 | final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); |
| 2405 | final List<BitcoinOutput> changeOutputs = outputs |
| 2406 | .where((output) => changeAddresses |
| 2407 | .any((element) => element.address == output.address.toAddress(network))) |
| 2408 | .toList(); |
| 2409 | |
| 2410 | int totalChangeAmount = |
| 2411 | changeOutputs.fold<int>(0, (sum, output) => sum + output.value.toInt()); |
| 2412 | |
| 2413 | // The final amount that the receiver will receive |
| 2414 | int sendingAmount = allInputsAmount - newFee - totalChangeAmount; |
| 2415 | |
| 2416 | final txb = BitcoinTransactionBuilder( |
| 2417 | utxos: utxos, |
| 2418 | outputs: outputs, |
| 2419 | fee: BigInt.from(newFee), |
| 2420 | network: network, |
| 2421 | memo: memo, |
| 2422 | inputOrdering: BitcoinOrdering.shuffle, |
| 2423 | outputOrdering: BitcoinOrdering.shuffle, |
| 2424 | enableRBF: true, |
| 2425 | locktime: await _antiFeeSnipingLocktime(), |
| 2426 | ); |
| 2427 | |
| 2428 | final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { |
| 2429 | final key = |
| 2430 | privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); |
| 2431 | if (key == null) { |
| 2432 | throw Exception("Cannot find private key"); |
| 2433 | } |
| 2434 | |
| 2435 | if (utxo.utxo.isP2tr()) { |
| 2436 | return key.signTapRoot(txDigest, sighash: sighash); |
| 2437 | } else { |
| 2438 | return key.signInput(txDigest, sigHash: sighash); |
| 2439 | } |
| 2440 | }); |
| 2441 | |
| 2442 | return PendingBitcoinTransaction( |
| 2443 | transaction, |
| 2444 | type, |
| 2445 | electrumClient: electrumClient, |
| 2446 | amount: Money.fromInt(sendingAmount, currency), |
| 2447 | fee: Money.fromInt(newFee, currency), |
| 2448 | network: network, |
| 2449 | hasChange: changeOutputs.isNotEmpty, |
| 2450 | feeRate: newFee.toString(), |
| 2451 | isViewOnly: keys.privateKey.isEmpty, |
| 2452 | )..addListener((transaction) async { |
| 2453 | transactionHistory.transactions.values.forEach((tx) { |
| 2454 | if (tx.id == hash) { |
| 2455 | tx.isReplaced = true; |
| 2456 | tx.isPending = false; |
| 2457 | transactionHistory.addOne(tx); |
| 2458 | } |
| 2459 | }); |
| 2460 | transactionHistory.addOne(transaction); |
| 2461 | await updateBalance(); |
| 2462 | await updateAllUnspents(); |
| 2463 | }); |
| 2464 | } catch (e) { |
| 2465 | throw e; |
| 2466 | } |
| 2467 | } |
| 2468 | |
| 2469 | Future<ElectrumTransactionBundle> getTransactionExpanded( |
| 2470 | {required String hash, int? height}) async { |
| 2471 | String transactionHex; |
| 2472 | int? time; |
| 2473 | int? confirmations; |
| 2474 | |
| 2475 | final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash); |
| 2476 | |
| 2477 | if (verboseTransaction.isEmpty) { |
| 2478 | transactionHex = await electrumClient.getTransactionHex(hash: hash); |
| 2479 | |
| 2480 | if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) { |
| 2481 | try { |
| 2482 | final blockHash = await ProxyWrapper() |
| 2483 | .get( |
| 2484 | clearnetUri: Uri.parse( |
| 2485 | "https://mempool.cakewallet.com/api/v1/block-height/$height", |
| 2486 | ), |
| 2487 | ) |
| 2488 | .timeout(Duration(seconds: 15)); |
| 2489 | |
| 2490 | if (blockHash.statusCode == 200 && |
| 2491 | blockHash.body.isNotEmpty && |
| 2492 | jsonDecode(blockHash.body) != null) { |
| 2493 | final blockResponse = await ProxyWrapper() |
| 2494 | .get( |
| 2495 | clearnetUri: Uri.parse( |
| 2496 | "https://mempool.cakewallet.com/api/v1/block/${blockHash.body}", |
| 2497 | ), |
| 2498 | ) |
| 2499 | .timeout(Duration(seconds: 15)); |
| 2500 | if (blockResponse.statusCode == 200 && |
| 2501 | blockResponse.body.isNotEmpty && |
| 2502 | jsonDecode(blockResponse.body)['timestamp'] != null) { |
| 2503 | time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()); |
| 2504 | } |
| 2505 | } |
| 2506 | } catch (_) {} |
| 2507 | } |
| 2508 | } else { |
| 2509 | transactionHex = verboseTransaction['hex'] as String; |
| 2510 | time = verboseTransaction['time'] as int?; |
| 2511 | confirmations = verboseTransaction['confirmations'] as int?; |
| 2512 | } |
| 2513 | |
| 2514 | if (height != null) { |
| 2515 | if (time == null && height > 0) { |
| 2516 | time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); |
| 2517 | } |
| 2518 | |
| 2519 | if (confirmations == null) { |
| 2520 | final tip = await getUpdatedChainTip(); |
| 2521 | if (tip > 0 && height > 0) { |
| 2522 | // Add one because the block itself is the first confirmation |
| 2523 | confirmations = tip - height + 1; |
| 2524 | } |
| 2525 | } |
| 2526 | } |
| 2527 | |
| 2528 | final original = BtcTransaction.fromRaw(transactionHex); |
| 2529 | final ins = <BtcTransaction?>[]; |
| 2530 | |
| 2531 | for (final vin in original.inputs) { |
| 2532 | try { |
| 2533 | final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); |
| 2534 | |
| 2535 | final String inputTransactionHex; |
| 2536 | |
| 2537 | if (verboseTransaction.isEmpty) { |
| 2538 | inputTransactionHex = await electrumClient.getTransactionHex(hash: vin.txId); |
| 2539 | } else { |
| 2540 | inputTransactionHex = verboseTransaction['hex'] as String; |
| 2541 | } |
| 2542 | |
| 2543 | ins.add(inputTransactionHex.isEmpty ? null : BtcTransaction.fromRaw(inputTransactionHex)); |
| 2544 | } catch (_) { |
| 2545 | ins.add(null); |
| 2546 | } |
| 2547 | } |
| 2548 | |
| 2549 | return ElectrumTransactionBundle( |
| 2550 | original, |
| 2551 | ins: ins, |
| 2552 | time: time, |
| 2553 | confirmations: confirmations ?? 0, |
| 2554 | ); |
| 2555 | } |
| 2556 | |
| 2557 | Future<ElectrumTransactionInfo?> fetchTransactionInfo( |
| 2558 | {required String hash, int? height, bool? retryOnFailure}) async { |
| 2559 | try { |
| 2560 | return ElectrumTransactionInfo.fromElectrumBundle( |
| 2561 | await getTransactionExpanded(hash: hash, height: height), |
| 2562 | walletInfo.type, |
| 2563 | network, |
| 2564 | addresses: addressesSet, |
| 2565 | height: height, |
| 2566 | ); |
| 2567 | } catch (e) { |
| 2568 | if (e is FormatException && retryOnFailure == true) { |
| 2569 | await Future.delayed(const Duration(seconds: 2)); |
| 2570 | return fetchTransactionInfo(hash: hash, height: height); |
| 2571 | } |
| 2572 | return null; |
| 2573 | } |
| 2574 | } |
| 2575 | |
| 2576 | bool isMine(Script script) { |
| 2577 | final derivedAddress = addressFromOutputScript(script, network); |
| 2578 | return addressesSet.contains(derivedAddress); |
| 2579 | } |
| 2580 | |
| 2581 | @override |
| 2582 | Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async { |
| 2583 | try { |
| 2584 | final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; |
| 2585 | ; |
| 2586 | |
| 2587 | printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching'); |
| 2588 | |
| 2589 | if (type == WalletType.bitcoin) { |
| 2590 | await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching |
| 2591 | ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) |
| 2592 | : fetchTransactionsForAddressType(historiesWithDetails, type))); |
| 2593 | } else if (type == WalletType.bitcoinCash) { |
| 2594 | await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => shouldUseBatchFetching |
| 2595 | ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) |
| 2596 | : fetchTransactionsForAddressType(historiesWithDetails, type))); |
| 2597 | } else if (type == WalletType.litecoin) { |
| 2598 | await Future.wait(LITECOIN_ADDRESS_TYPES.where((type) => type != SegwitAddresType.mweb).map( |
| 2599 | (type) => shouldUseBatchFetching |
| 2600 | ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) |
| 2601 | : fetchTransactionsForAddressType(historiesWithDetails, type))); |
| 2602 | } else if (type == WalletType.dogecoin) { |
| 2603 | await Future.wait(DOGECOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching |
| 2604 | ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) |
| 2605 | : fetchTransactionsForAddressType(historiesWithDetails, type))); |
| 2606 | } |
| 2607 | |
| 2608 | transactionHistory.transactions.values.forEach((tx) async { |
| 2609 | final isPendingSilentPaymentUtxo = |
| 2610 | (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; |
| 2611 | |
| 2612 | if (isPendingSilentPaymentUtxo) { |
| 2613 | final info = |
| 2614 | await fetchTransactionInfo(hash: tx.id, height: tx.height, retryOnFailure: true); |
| 2615 | |
| 2616 | if (info != null) { |
| 2617 | tx.confirmations = info.confirmations; |
| 2618 | tx.isPending = tx.confirmations == 0; |
| 2619 | transactionHistory.addOne(tx); |
| 2620 | await transactionHistory.save(); |
| 2621 | } |
| 2622 | } |
| 2623 | }); |
| 2624 | |
| 2625 | return historiesWithDetails; |
| 2626 | } catch (e) { |
| 2627 | printV("fetchTransactions $e"); |
| 2628 | return {}; |
| 2629 | } |
| 2630 | } |
| 2631 | |
| 2632 | Future<void> fetchTransactionsForAddressType( |
| 2633 | Map<String, ElectrumTransactionInfo> historiesWithDetails, |
| 2634 | BitcoinAddressType type, |
| 2635 | ) async { |
| 2636 | final addressesByType = |
| 2637 | walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); |
| 2638 | |
| 2639 | final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); |
| 2640 | final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); |
| 2641 | final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); |
| 2642 | final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); |
| 2643 | |
| 2644 | walletAddresses.hiddenAddresses |
| 2645 | .addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); |
| 2646 | await walletAddresses.saveAddressesInBox(); |
| 2647 | await Future.wait(addressesByType.map((addressRecord) async { |
| 2648 | final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); |
| 2649 | |
| 2650 | if (history.isNotEmpty) { |
| 2651 | addressRecord.txCount = history.length; |
| 2652 | historiesWithDetails.addAll(history); |
| 2653 | |
| 2654 | final matchedAddresses = addressRecord.isHidden |
| 2655 | ? (addressRecord.isLegacyDerivation ? changeLegacy : changeStandard) |
| 2656 | : (addressRecord.isLegacyDerivation ? receiveLegacy : receiveStandard); |
| 2657 | final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >= |
| 2658 | matchedAddresses.length - |
| 2659 | (addressRecord.isHidden |
| 2660 | ? ElectrumWalletAddressesBase.defaultChangeAddressesCount |
| 2661 | : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); |
| 2662 | |
| 2663 | if (isUsedAddressAboveGap) { |
| 2664 | final prevLength = walletAddresses.allAddresses.length; |
| 2665 | |
| 2666 | // Discover new addresses for the same address type until the gap limit is respected |
| 2667 | await walletAddresses.discoverAddresses( |
| 2668 | matchedAddresses.toList(), |
| 2669 | addressRecord.isHidden, |
| 2670 | (address) async { |
| 2671 | await subscribeForUpdates(); |
| 2672 | return _fetchAddressHistory(address, await getCurrentChainTip()) |
| 2673 | .then((history) => history.isNotEmpty ? address.address : null); |
| 2674 | }, |
| 2675 | type: type, |
| 2676 | isLegacyDerivation: addressRecord.isLegacyDerivation, |
| 2677 | ); |
| 2678 | |
| 2679 | final newLength = walletAddresses.allAddresses.length; |
| 2680 | |
| 2681 | if (newLength > prevLength) { |
| 2682 | await fetchTransactionsForAddressType(historiesWithDetails, type); |
| 2683 | } |
| 2684 | } |
| 2685 | } |
| 2686 | })); |
| 2687 | } |
| 2688 | |
| 2689 | Future<Map<String, ElectrumTransactionInfo>> _fetchAddressHistory( |
| 2690 | BitcoinAddressRecord addressRecord, int? currentHeight) async { |
| 2691 | String txid = ""; |
| 2692 | |
| 2693 | try { |
| 2694 | final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; |
| 2695 | |
| 2696 | final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); |
| 2697 | |
| 2698 | if (history.isNotEmpty) { |
| 2699 | addressRecord.setAsUsed(); |
| 2700 | walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); |
| 2701 | |
| 2702 | if (this is BitcoinWallet) { |
| 2703 | //removes transactions no longer returned by the api, presumed replaced/invalid. |
| 2704 | transactionHistory.transactions.removeWhere( |
| 2705 | (hash, tx) => |
| 2706 | tx.outputAddresses != null && |
| 2707 | tx.outputAddresses!.contains(addressRecord.address) && |
| 2708 | !history.any((newTransaction) => newTransaction['tx_hash'] == hash), |
| 2709 | ); |
| 2710 | } |
| 2711 | |
| 2712 | await Future.wait(history.map((transaction) async { |
| 2713 | txid = transaction['tx_hash'] as String; |
| 2714 | final height = transaction['height'] as int; |
| 2715 | final storedTx = transactionHistory.transactions[txid]; |
| 2716 | |
| 2717 | if (storedTx != null) { |
| 2718 | if (height > 0) { |
| 2719 | storedTx.height = height; |
| 2720 | // the tx's block itself is the first confirmation so add 1 |
| 2721 | if ((currentHeight ?? 0) > 0) { |
| 2722 | storedTx.confirmations = currentHeight! - height + 1; |
| 2723 | } |
| 2724 | storedTx.isPending = storedTx.confirmations == 0; |
| 2725 | } |
| 2726 | |
| 2727 | historiesWithDetails[txid] = storedTx; |
| 2728 | } else { |
| 2729 | final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); |
| 2730 | |
| 2731 | if (tx != null) { |
| 2732 | historiesWithDetails[txid] = tx; |
| 2733 | |
| 2734 | // Got a new transaction fetched, add it to the transaction history |
| 2735 | // instead of waiting all to finish, and next time it will be faster |
| 2736 | |
| 2737 | _applyLitecoinPegOutTag(tx); |
| 2738 | transactionHistory.addOne(tx); |
| 2739 | await transactionHistory.save(); |
| 2740 | } |
| 2741 | } |
| 2742 | |
| 2743 | return Future.value(null); |
| 2744 | })); |
| 2745 | } |
| 2746 | |
| 2747 | return historiesWithDetails; |
| 2748 | } catch (e, stacktrace) { |
| 2749 | _onError?.call(FlutterErrorDetails( |
| 2750 | exception: "$txid - $e", |
| 2751 | stack: stacktrace, |
| 2752 | library: this.runtimeType.toString(), |
| 2753 | )); |
| 2754 | return {}; |
| 2755 | } |
| 2756 | } |
| 2757 | |
| 2758 | Future<void> fetchTransactionsForAddressTypeBatch( |
| 2759 | Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async { |
| 2760 | final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); |
| 2761 | final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); |
| 2762 | final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); |
| 2763 | final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); |
| 2764 | |
| 2765 | walletAddresses.hiddenAddresses |
| 2766 | .addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); |
| 2767 | await walletAddresses.saveAddressesInBox(); |
| 2768 | |
| 2769 | await fetchTransactionsForAddressesBranchBatch( |
| 2770 | historiesWithDetails, |
| 2771 | type, |
| 2772 | receiveStandard, |
| 2773 | isHidden: false, |
| 2774 | isLegacyDerivation: false, |
| 2775 | ); |
| 2776 | |
| 2777 | await fetchTransactionsForAddressesBranchBatch( |
| 2778 | historiesWithDetails, |
| 2779 | type, |
| 2780 | changeStandard, |
| 2781 | isHidden: true, |
| 2782 | isLegacyDerivation: false, |
| 2783 | ); |
| 2784 | |
| 2785 | await fetchTransactionsForAddressesBranchBatch( |
| 2786 | historiesWithDetails, |
| 2787 | type, |
| 2788 | receiveLegacy, |
| 2789 | isHidden: false, |
| 2790 | isLegacyDerivation: true, |
| 2791 | ); |
| 2792 | |
| 2793 | await fetchTransactionsForAddressesBranchBatch( |
| 2794 | historiesWithDetails, |
| 2795 | type, |
| 2796 | changeLegacy, |
| 2797 | isHidden: true, |
| 2798 | isLegacyDerivation: true, |
| 2799 | ); |
| 2800 | } |
| 2801 | |
| 2802 | Future<void> fetchTransactionsForAddressesBranchBatch( |
| 2803 | Map<String, ElectrumTransactionInfo> historiesWithDetails, |
| 2804 | BitcoinAddressType type, |
| 2805 | List<BitcoinAddressRecord> branchAddresses, { |
| 2806 | required bool isHidden, |
| 2807 | required bool isLegacyDerivation, |
| 2808 | }) async { |
| 2809 | if (branchAddresses.isEmpty) return; |
| 2810 | |
| 2811 | final tip = await getCurrentChainTip(); |
| 2812 | final currentBranch = [...branchAddresses]; |
| 2813 | |
| 2814 | final initialHistory = |
| 2815 | await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>( |
| 2816 | items: currentBranch, |
| 2817 | chunkSize: addressHistoryChunkSize, |
| 2818 | processChunk: (chunk) => _fetchBatchAddressHistory( |
| 2819 | chunk, |
| 2820 | tip, |
| 2821 | addressHistoryChunkSize, |
| 2822 | ), |
| 2823 | ); |
| 2824 | |
| 2825 | if (initialHistory.isNotEmpty) { |
| 2826 | historiesWithDetails.addAll(initialHistory); |
| 2827 | } |
| 2828 | |
| 2829 | final gapLimit = isHidden |
| 2830 | ? ElectrumWalletAddressesBase.defaultChangeAddressesCount |
| 2831 | : ElectrumWalletAddressesBase.defaultReceiveAddressesCount; |
| 2832 | |
| 2833 | final highestUsedIndex = _highestUsedIndex(currentBranch); |
| 2834 | final shouldDiscover = |
| 2835 | highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit; |
| 2836 | |
| 2837 | if (!shouldDiscover) return; |
| 2838 | |
| 2839 | final newAddresses = await walletAddresses.discoverAddressesBatch( |
| 2840 | currentBranch, |
| 2841 | isHidden, |
| 2842 | (newAddresses) async { |
| 2843 | final newHistory = await _fetchBatchAddressHistory( |
| 2844 | newAddresses, |
| 2845 | tip, |
| 2846 | discoveryHistoryChunkSize, |
| 2847 | ); |
| 2848 | |
| 2849 | if (newHistory.isNotEmpty) { |
| 2850 | historiesWithDetails.addAll(newHistory); |
| 2851 | } |
| 2852 | |
| 2853 | return newAddresses |
| 2854 | .where((addressRecord) => addressRecord.isUsed) |
| 2855 | .map((addressRecord) => addressRecord.address) |
| 2856 | .toSet(); |
| 2857 | }, |
| 2858 | type: type, |
| 2859 | isLegacyDerivation: isLegacyDerivation, |
| 2860 | ); |
| 2861 | |
| 2862 | if (newAddresses.isNotEmpty) { |
| 2863 | currentBranch.addAll(newAddresses); |
| 2864 | |
| 2865 | if (isHidden) { |
| 2866 | walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address)); |
| 2867 | await walletAddresses.saveAddressesInBox(); |
| 2868 | } |
| 2869 | } |
| 2870 | } |
| 2871 | |
| 2872 | List<BitcoinAddressRecord> getAddressBranchByType( |
| 2873 | {required bool hidden, required bool legacy, required BitcoinAddressType type}) => |
| 2874 | walletAddresses.allAddresses |
| 2875 | .where((addr) => |
| 2876 | addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy) |
| 2877 | .toList() |
| 2878 | ..sort((a, b) => a.index.compareTo(b.index)); |
| 2879 | |
| 2880 | int _highestUsedIndex(List<BitcoinAddressRecord> addresses) { |
| 2881 | for (int i = addresses.length - 1; i >= 0; i--) { |
| 2882 | if (addresses[i].isUsed) return i; |
| 2883 | } |
| 2884 | return -1; |
| 2885 | } |
| 2886 | |
| 2887 | Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory( |
| 2888 | List<BitcoinAddressRecord> addressRecords, int? currentHeight, int historyChunkSize) async { |
| 2889 | String lastTxId = ''; |
| 2890 | bool didUpdateHistory = false; |
| 2891 | |
| 2892 | try { |
| 2893 | final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; |
| 2894 | |
| 2895 | // List of script hashes for the given address records |
| 2896 | final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); |
| 2897 | |
| 2898 | final historyByScriptHash = |
| 2899 | await _processChunksToMap<String, String, List<Map<String, dynamic>>>( |
| 2900 | items: scriptHashes, chunkSize: historyChunkSize, processChunk: _getHistoryBatch); |
| 2901 | |
| 2902 | // Map scriptHash -> addressRecord |
| 2903 | final byScriptHash = <String, BitcoinAddressRecord>{}; |
| 2904 | for (final a in addressRecords) { |
| 2905 | byScriptHash[a.getScriptHash(network)] = a; |
| 2906 | } |
| 2907 | |
| 2908 | // Split into already-known txs vs missing txs |
| 2909 | final missingHistoryItems = <Map<String, dynamic>>[]; |
| 2910 | |
| 2911 | for (final entry in historyByScriptHash.entries) { |
| 2912 | final sh = entry.key; |
| 2913 | final addressRecord = byScriptHash[sh]; |
| 2914 | if (addressRecord == null) continue; |
| 2915 | |
| 2916 | final history = entry.value; |
| 2917 | if (history.isEmpty) continue; |
| 2918 | |
| 2919 | addressRecord.setAsUsed(); |
| 2920 | walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); |
| 2921 | |
| 2922 | //removes transactions no longer returned by the api, presumed replaced/invalid. |
| 2923 | if (this is BitcoinWallet) { |
| 2924 | final beforeLen = transactionHistory.transactions.length; |
| 2925 | transactionHistory.transactions.removeWhere((hash, tx) { |
| 2926 | return tx.outputAddresses != null && |
| 2927 | tx.outputAddresses!.contains(addressRecord.address) && |
| 2928 | !history.any((h) => h['tx_hash'] == hash); |
| 2929 | }); |
| 2930 | if (transactionHistory.transactions.length != beforeLen) { |
| 2931 | didUpdateHistory = true; |
| 2932 | } |
| 2933 | } |
| 2934 | |
| 2935 | // For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later. |
| 2936 | for (final item in history) { |
| 2937 | final txid = item['tx_hash'] as String?; |
| 2938 | final height = item['height'] as int? ?? 0; |
| 2939 | if (txid == null || txid.isEmpty) continue; |
| 2940 | |
| 2941 | lastTxId = txid; |
| 2942 | |
| 2943 | final storedTx = transactionHistory.transactions[txid]; |
| 2944 | if (storedTx != null) { |
| 2945 | if (height > 0) { |
| 2946 | final oldHeight = storedTx.height; |
| 2947 | final oldConfs = storedTx.confirmations; |
| 2948 | final oldPending = storedTx.isPending; |
| 2949 | |
| 2950 | storedTx.height = height; |
| 2951 | |
| 2952 | if ((currentHeight ?? 0) > 0) { |
| 2953 | storedTx.confirmations = currentHeight! - height + 1; |
| 2954 | } |
| 2955 | |
| 2956 | storedTx.isPending = storedTx.confirmations == 0; |
| 2957 | |
| 2958 | if (storedTx.height != oldHeight || |
| 2959 | storedTx.confirmations != oldConfs || |
| 2960 | storedTx.isPending != oldPending) { |
| 2961 | transactionHistory.addOne(storedTx); |
| 2962 | didUpdateHistory = true; |
| 2963 | } |
| 2964 | } |
| 2965 | |
| 2966 | historiesWithDetails[txid] = storedTx; |
| 2967 | } else { |
| 2968 | missingHistoryItems.add({ |
| 2969 | 'tx_hash': txid, |
| 2970 | 'height': height, |
| 2971 | 'script_hash': sh, |
| 2972 | 'address': addressRecord.address, |
| 2973 | }); |
| 2974 | } |
| 2975 | } |
| 2976 | } |
| 2977 | |
| 2978 | // Batch fetch missing tx verbose details |
| 2979 | if (missingHistoryItems.isEmpty) { |
| 2980 | if (didUpdateHistory) await transactionHistory.save(); |
| 2981 | return historiesWithDetails; |
| 2982 | } |
| 2983 | |
| 2984 | for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) { |
| 2985 | final end = (i + historyChunkSize < missingHistoryItems.length) |
| 2986 | ? i + historyChunkSize |
| 2987 | : missingHistoryItems.length; |
| 2988 | final chunkHistory = missingHistoryItems.sublist(i, end); |
| 2989 | |
| 2990 | final hashes = chunkHistory |
| 2991 | .map((e) => (e['tx_hash'] as String).trim()) |
| 2992 | .where((h) => h.isNotEmpty) |
| 2993 | .toList(growable: false); |
| 2994 | |
| 2995 | final heightsByHash = <String, int?>{ |
| 2996 | for (final e in chunkHistory) (e['tx_hash'] as String): (e['height'] as int?), |
| 2997 | }; |
| 2998 | |
| 2999 | final infosByHash = await fetchTransactionInfoBatch( |
| 3000 | hashes: hashes, |
| 3001 | heightsByHash: heightsByHash, |
| 3002 | retryOnFailure: true, |
| 3003 | retryDelay: const Duration(seconds: 1), |
| 3004 | ); |
| 3005 | |
| 3006 | for (final txid in hashes) { |
| 3007 | final tx = infosByHash[txid]; |
| 3008 | if (tx == null) continue; |
| 3009 | |
| 3010 | historiesWithDetails[tx.id] = tx; |
| 3011 | |
| 3012 | _applyLitecoinPegOutTag(tx); |
| 3013 | |
| 3014 | transactionHistory.addOne(tx); |
| 3015 | didUpdateHistory = true; |
| 3016 | } |
| 3017 | } |
| 3018 | |
| 3019 | if (didUpdateHistory) { |
| 3020 | await transactionHistory.save(); |
| 3021 | } |
| 3022 | |
| 3023 | return historiesWithDetails; |
| 3024 | } catch (e, stacktrace) { |
| 3025 | final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; |
| 3026 | _onError?.call(FlutterErrorDetails( |
| 3027 | exception: '$prefix$e', |
| 3028 | stack: stacktrace, |
| 3029 | library: runtimeType.toString(), |
| 3030 | )); |
| 3031 | return {}; |
| 3032 | } |
| 3033 | } |
| 3034 | |
| 3035 | Future<Map<String, Map<String, dynamic>>> _getTransactionVerboseBatch(List<String> hashes) { |
| 3036 | return electrumClient.getBatchTransactionVerbose( |
| 3037 | hashes, |
| 3038 | timeout: transactionBatchTimeoutMs, |
| 3039 | ); |
| 3040 | } |
| 3041 | |
| 3042 | Future<Map<String, String?>> _getTransactionHexBatch(List<String> hashes) { |
| 3043 | return electrumClient.getBatchTransactionHex( |
| 3044 | hashes, |
| 3045 | timeout: transactionBatchTimeoutMs, |
| 3046 | ); |
| 3047 | } |
| 3048 | |
| 3049 | Future<Map<String, List<Map<String, dynamic>>>> _getHistoryBatch(List<String> scriptHashes) { |
| 3050 | return electrumClient.getBatchHistory( |
| 3051 | scriptHashes, |
| 3052 | timeout: transactionBatchTimeoutMs, |
| 3053 | ); |
| 3054 | } |
| 3055 | |
| 3056 | Future<Map<String, List<Map<String, dynamic>>>> _getListUnspentBatch(List<String> scriptHashes) { |
| 3057 | return electrumClient.getBatchUnspent( |
| 3058 | scriptHashes, |
| 3059 | timeout: transactionBatchTimeoutMs, |
| 3060 | ); |
| 3061 | } |
| 3062 | |
| 3063 | Future<Map<String, Map<String, dynamic>>> _getBalanceBatch(List<String> scriptHashes) { |
| 3064 | return electrumClient.getBatchBalance( |
| 3065 | scriptHashes, |
| 3066 | timeout: transactionBatchTimeoutMs, |
| 3067 | ); |
| 3068 | } |
| 3069 | |
| 3070 | Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({ |
| 3071 | required List<String> hashes, |
| 3072 | Map<String, int?>? heightsByHash, |
| 3073 | bool retryOnFailure = false, |
| 3074 | Duration retryDelay = const Duration(seconds: 2), |
| 3075 | }) async { |
| 3076 | final result = <String, ElectrumTransactionInfo?>{}; |
| 3077 | final uniqueHashes = hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList(); |
| 3078 | |
| 3079 | if (uniqueHashes.isEmpty) return result; |
| 3080 | |
| 3081 | await _processTransactionInfoBatch( |
| 3082 | txIds: uniqueHashes, |
| 3083 | result: result, |
| 3084 | heightsByHash: heightsByHash, |
| 3085 | ); |
| 3086 | |
| 3087 | if (retryOnFailure) { |
| 3088 | final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList(); |
| 3089 | |
| 3090 | if (failedHashes.isNotEmpty) { |
| 3091 | await Future.delayed(retryDelay); |
| 3092 | |
| 3093 | await _processTransactionInfoBatch( |
| 3094 | txIds: failedHashes, |
| 3095 | result: result, |
| 3096 | heightsByHash: heightsByHash, |
| 3097 | ); |
| 3098 | } |
| 3099 | } |
| 3100 | |
| 3101 | return result; |
| 3102 | } |
| 3103 | |
| 3104 | Future<void> _processTransactionInfoBatch({ |
| 3105 | required List<String> txIds, |
| 3106 | required Map<String, ElectrumTransactionInfo?> result, |
| 3107 | required Map<String, int?>? heightsByHash, |
| 3108 | }) async { |
| 3109 | for (var i = 0; i < txIds.length; i += transactionChunkSize) { |
| 3110 | final end = |
| 3111 | (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; |
| 3112 | final chunk = txIds.sublist(i, end); |
| 3113 | |
| 3114 | final bundlesByHash = await getTransactionExpandedBatch( |
| 3115 | hashes: chunk, |
| 3116 | heightsByHash: heightsByHash, |
| 3117 | ); |
| 3118 | |
| 3119 | for (final txId in chunk) { |
| 3120 | try { |
| 3121 | final bundle = bundlesByHash[txId]; |
| 3122 | if (bundle == null) { |
| 3123 | result[txId] = null; |
| 3124 | continue; |
| 3125 | } |
| 3126 | |
| 3127 | final info = ElectrumTransactionInfo.fromElectrumBundle( |
| 3128 | bundle, |
| 3129 | walletInfo.type, |
| 3130 | network, |
| 3131 | addresses: addressesSet, |
| 3132 | height: heightsByHash?[txId], |
| 3133 | ); |
| 3134 | info.id = txId; |
| 3135 | result[txId] = info; |
| 3136 | } catch (_) { |
| 3137 | result[txId] = null; |
| 3138 | } |
| 3139 | } |
| 3140 | } |
| 3141 | } |
| 3142 | |
| 3143 | Future<Map<String, ElectrumTransactionBundle>> getTransactionExpandedBatch( |
| 3144 | {required List<String> hashes, Map<String, int?>? heightsByHash}) async { |
| 3145 | final bundles = <String, ElectrumTransactionBundle>{}; |
| 3146 | if (hashes.isEmpty) return bundles; |
| 3147 | |
| 3148 | final verboseByHash = await _fetchTransactionVerboseBatch(hashes); |
| 3149 | |
| 3150 | final originalByHash = _parseTransactions(verboseByHash); |
| 3151 | |
| 3152 | final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash); |
| 3153 | |
| 3154 | final allInputTxids = <String>{}; |
| 3155 | for (final txids in inputTxIdsByHash.values) { |
| 3156 | allInputTxids.addAll(txids); |
| 3157 | } |
| 3158 | |
| 3159 | final inputTxIds = allInputTxids.toList(growable: false); |
| 3160 | |
| 3161 | final inputVerboseByTxId = inputTxIds.isEmpty |
| 3162 | ? <String, Map<String, dynamic>>{} |
| 3163 | : await _fetchTransactionVerboseBatch(inputTxIds); |
| 3164 | |
| 3165 | final parsedInputTxById = _parseTransactions(inputVerboseByTxId); |
| 3166 | |
| 3167 | return _buildTransactionBundlesBatch( |
| 3168 | unique: hashes, |
| 3169 | heightsByHash: heightsByHash, |
| 3170 | tip: await getUpdatedChainTip(), |
| 3171 | originalByHash: originalByHash, |
| 3172 | verboseByHash: verboseByHash, |
| 3173 | inputTxidsByHash: inputTxIdsByHash, |
| 3174 | parsedInputTxById: parsedInputTxById, |
| 3175 | ); |
| 3176 | } |
| 3177 | |
| 3178 | Future<Map<String, Map<String, dynamic>>> _fetchTransactionVerboseBatch( |
| 3179 | List<String> txIds) async { |
| 3180 | final verboseTransactionByHash = |
| 3181 | await _processChunksToMap<String, String, Map<String, dynamic>>( |
| 3182 | items: txIds, |
| 3183 | chunkSize: transactionChunkSize, |
| 3184 | processChunk: _getTransactionVerboseBatch, |
| 3185 | ); |
| 3186 | |
| 3187 | final emptyHex = <String>[]; |
| 3188 | for (final txId in txIds) { |
| 3189 | final vTx = verboseTransactionByHash[txId]; |
| 3190 | if (vTx == null || vTx.isEmpty || vTx['hex'] == null) { |
| 3191 | emptyHex.add(txId); |
| 3192 | } |
| 3193 | } |
| 3194 | |
| 3195 | final hexByHash = await _processChunksToMap<String, String, String?>( |
| 3196 | items: emptyHex, |
| 3197 | chunkSize: transactionChunkSize, |
| 3198 | processChunk: _getTransactionHexBatch, |
| 3199 | ); |
| 3200 | |
| 3201 | for (final txId in txIds) { |
| 3202 | final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{}; |
| 3203 | if ((verbose['hex'] as String?) == null) { |
| 3204 | final hex = hexByHash[txId]; |
| 3205 | if (hex != null && hex.isNotEmpty) { |
| 3206 | verboseTransactionByHash[txId] = { |
| 3207 | ...verbose, |
| 3208 | 'hex': hex, |
| 3209 | }; |
| 3210 | } |
| 3211 | } |
| 3212 | } |
| 3213 | |
| 3214 | return verboseTransactionByHash; |
| 3215 | } |
| 3216 | |
| 3217 | Map<String, BtcTransaction> _parseTransactions( |
| 3218 | Map<String, Map<String, dynamic>> verboseByHash, |
| 3219 | ) { |
| 3220 | final result = <String, BtcTransaction>{}; |
| 3221 | |
| 3222 | for (final entry in verboseByHash.entries) { |
| 3223 | final hex = entry.value['hex'] as String?; |
| 3224 | if (hex == null || hex.isEmpty) continue; |
| 3225 | |
| 3226 | try { |
| 3227 | result[entry.key] = BtcTransaction.fromRaw(hex); |
| 3228 | } catch (_) {} |
| 3229 | } |
| 3230 | |
| 3231 | return result; |
| 3232 | } |
| 3233 | |
| 3234 | Map<String, List<String>> _collectInputTxIdsByHash( |
| 3235 | Map<String, BtcTransaction> originalByHash, |
| 3236 | ) { |
| 3237 | final inputTxIdsByHash = <String, List<String>>{}; |
| 3238 | |
| 3239 | for (final entry in originalByHash.entries) { |
| 3240 | final txId = entry.key; |
| 3241 | final original = entry.value; |
| 3242 | |
| 3243 | final inputTxIds = <String>[]; |
| 3244 | for (final vin in original.inputs) { |
| 3245 | inputTxIds.add(vin.txId); |
| 3246 | } |
| 3247 | |
| 3248 | inputTxIdsByHash[txId] = inputTxIds; |
| 3249 | } |
| 3250 | |
| 3251 | return inputTxIdsByHash; |
| 3252 | } |
| 3253 | |
| 3254 | Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({ |
| 3255 | required List<String> unique, |
| 3256 | required Map<String, int?>? heightsByHash, |
| 3257 | required int tip, |
| 3258 | required Map<String, BtcTransaction> originalByHash, |
| 3259 | required Map<String, Map<String, dynamic>> verboseByHash, |
| 3260 | required Map<String, List<String>> inputTxidsByHash, |
| 3261 | required Map<String, BtcTransaction> parsedInputTxById, |
| 3262 | }) async { |
| 3263 | final bundles = <String, ElectrumTransactionBundle>{}; |
| 3264 | |
| 3265 | // Identify heights that need mempool timestamp lookup |
| 3266 | final heightsNeedingTime = <int>{}; |
| 3267 | for (final txid in originalByHash.keys) { |
| 3268 | final verbose = verboseByHash[txid] ?? const <String, dynamic>{}; |
| 3269 | final time = verbose['time'] as int?; |
| 3270 | final h = heightsByHash?[txid]; |
| 3271 | if (time == null && h != null && h > 0) { |
| 3272 | heightsNeedingTime.add(h); |
| 3273 | } |
| 3274 | } |
| 3275 | |
| 3276 | final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime); |
| 3277 | |
| 3278 | for (final txid in unique) { |
| 3279 | final original = originalByHash[txid]; |
| 3280 | if (original == null) continue; |
| 3281 | |
| 3282 | final verbose = verboseByHash[txid] ?? const <String, dynamic>{}; |
| 3283 | |
| 3284 | int? time = verbose['time'] as int?; |
| 3285 | int? confirmations = verbose['confirmations'] as int?; |
| 3286 | final h = heightsByHash?[txid]; |
| 3287 | |
| 3288 | if (h != null) { |
| 3289 | if (time == null && h > 0) { |
| 3290 | final mp = mempoolTimes[h]; |
| 3291 | time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round(); |
| 3292 | } |
| 3293 | |
| 3294 | if (confirmations == null && tip > 0 && h > 0) { |
| 3295 | confirmations = tip - h + 1; |
| 3296 | if (confirmations < 0) confirmations = 0; |
| 3297 | } |
| 3298 | } |
| 3299 | |
| 3300 | final inputTxids = inputTxidsByHash[txid] ?? const <String>[]; |
| 3301 | |
| 3302 | final ins = <BtcTransaction?>[ |
| 3303 | for (final inputTxid in inputTxids) parsedInputTxById[inputTxid], |
| 3304 | ]; |
| 3305 | |
| 3306 | bundles[txid] = ElectrumTransactionBundle( |
| 3307 | original, |
| 3308 | ins: ins, |
| 3309 | time: time, |
| 3310 | confirmations: confirmations ?? 0, |
| 3311 | ); |
| 3312 | } |
| 3313 | |
| 3314 | return bundles; |
| 3315 | } |
| 3316 | |
| 3317 | Future<Map<int, int>> _fetchBlockTimestampsFromMempoolByHeights( |
| 3318 | Set<int> heights, |
| 3319 | ) async { |
| 3320 | final out = <int, int>{}; |
| 3321 | if (heights.isEmpty) return out; |
| 3322 | if (!(await checkIfMempoolAPIIsEnabled())) return out; |
| 3323 | |
| 3324 | // Best-effort: if any call fails, we just skip that height. |
| 3325 | await Future.wait(heights.map((h) async { |
| 3326 | try { |
| 3327 | final blockHashResp = await ProxyWrapper() |
| 3328 | .get( |
| 3329 | clearnetUri: Uri.parse( |
| 3330 | 'https://mempool.cakewallet.com/api/v1/block-height/$h', |
| 3331 | ), |
| 3332 | ) |
| 3333 | .timeout(const Duration(seconds: 15)); |
| 3334 | |
| 3335 | if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return; |
| 3336 | |
| 3337 | final blockHash = blockHashResp.body.trim(); |
| 3338 | if (blockHash.isEmpty) return; |
| 3339 | |
| 3340 | final blockResp = await ProxyWrapper() |
| 3341 | .get( |
| 3342 | clearnetUri: Uri.parse( |
| 3343 | 'https://mempool.cakewallet.com/api/v1/block/$blockHash', |
| 3344 | ), |
| 3345 | ) |
| 3346 | .timeout(const Duration(seconds: 15)); |
| 3347 | |
| 3348 | if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return; |
| 3349 | |
| 3350 | final decoded = jsonDecode(blockResp.body); |
| 3351 | final ts = decoded is Map<String, dynamic> ? decoded['timestamp'] : null; |
| 3352 | if (ts == null) return; |
| 3353 | |
| 3354 | final parsed = int.tryParse(ts.toString()); |
| 3355 | if (parsed == null) return; |
| 3356 | |
| 3357 | out[h] = parsed; |
| 3358 | } catch (_) { |
| 3359 | // ignore |
| 3360 | } |
| 3361 | })); |
| 3362 | |
| 3363 | return out; |
| 3364 | } |
| 3365 | |
| 3366 | Future<Map<K, V>> _processChunksToMap<T, K, V>({ |
| 3367 | required List<T> items, |
| 3368 | required int chunkSize, |
| 3369 | required Future<Map<K, V>> Function(List<T> chunk) processChunk, |
| 3370 | void Function(List<T> chunk, Object error)? onChunkError, |
| 3371 | }) async { |
| 3372 | final result = <K, V>{}; |
| 3373 | |
| 3374 | for (var i = 0; i < items.length; i += chunkSize) { |
| 3375 | final end = (i + chunkSize < items.length) ? i + chunkSize : items.length; |
| 3376 | final chunk = items.sublist(i, end); |
| 3377 | |
| 3378 | try { |
| 3379 | final chunkResult = await processChunk(chunk); |
| 3380 | result.addAll(chunkResult); |
| 3381 | } on electrum.RequestFailedTimeoutException catch (e) { |
| 3382 | onChunkError?.call(chunk, e); |
| 3383 | continue; |
| 3384 | } catch (e) { |
| 3385 | onChunkError?.call(chunk, e); |
| 3386 | continue; |
| 3387 | } |
| 3388 | } |
| 3389 | |
| 3390 | return result; |
| 3391 | } |
| 3392 | |
| 3393 | Future<void> updateTransactions() async { |
| 3394 | printV("updateTransactions() called!"); |
| 3395 | try { |
| 3396 | if (_isTransactionUpdating) { |
| 3397 | return; |
| 3398 | } |
| 3399 | currentChainTip = await getUpdatedChainTip(); |
| 3400 | |
| 3401 | bool updated = false; |
| 3402 | transactionHistory.transactions.values.forEach((tx) { |
| 3403 | if ((tx.height ?? 0) > 0 && (currentChainTip ?? 0) > 0) { |
| 3404 | var confirmations = currentChainTip! - tx.height! + 1; |
| 3405 | if (confirmations < 0) { |
| 3406 | // if our chain tip is outdated then it could lead to negative confirmations so this is just a failsafe: |
| 3407 | confirmations = 0; |
| 3408 | } |
| 3409 | if (confirmations != tx.confirmations) { |
| 3410 | updated = true; |
| 3411 | tx.confirmations = confirmations; |
| 3412 | transactionHistory.addOne(tx); |
| 3413 | } |
| 3414 | } |
| 3415 | }); |
| 3416 | |
| 3417 | if (updated) { |
| 3418 | await transactionHistory.save(); |
| 3419 | } |
| 3420 | |
| 3421 | _isTransactionUpdating = true; |
| 3422 | await fetchTransactions(); |
| 3423 | walletAddresses.updateReceiveAddresses(); |
| 3424 | _isTransactionUpdating = false; |
| 3425 | } catch (e, stacktrace) { |
| 3426 | printV(stacktrace); |
| 3427 | printV(e); |
| 3428 | _isTransactionUpdating = false; |
| 3429 | } |
| 3430 | } |
| 3431 | |
| 3432 | Future<void> subscribeForUpdates() async { |
| 3433 | final unsubscribedScriptHashes = walletAddresses.allAddresses.where( |
| 3434 | (address) => |
| 3435 | !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) && |
| 3436 | address.type != SegwitAddresType.mweb, |
| 3437 | ); |
| 3438 | |
| 3439 | await Future.wait(unsubscribedScriptHashes.map((address) async { |
| 3440 | final sh = address.getScriptHash(network); |
| 3441 | if (!(_scripthashesUpdateSubject[sh]?.isClosed ?? true)) { |
| 3442 | try { |
| 3443 | await _scripthashesUpdateSubject[sh]?.close(); |
| 3444 | } catch (e) { |
| 3445 | printV("failed to close: $e"); |
| 3446 | } |
| 3447 | } |
| 3448 | try { |
| 3449 | _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); |
| 3450 | } catch (e) { |
| 3451 | printV("failed scripthashUpdate: $e"); |
| 3452 | } |
| 3453 | _scripthashesUpdateSubject[sh]?.listen((event) async { |
| 3454 | try { |
| 3455 | await updateUnspentsForAddress(address); |
| 3456 | |
| 3457 | await updateBalance(); |
| 3458 | |
| 3459 | await _fetchAddressHistory(address, await getCurrentChainTip()); |
| 3460 | } catch (e, s) { |
| 3461 | printV("sub error: $e"); |
| 3462 | _onError?.call(FlutterErrorDetails( |
| 3463 | exception: e, |
| 3464 | stack: s, |
| 3465 | library: this.runtimeType.toString(), |
| 3466 | )); |
| 3467 | } |
| 3468 | }, onError: (e, s) { |
| 3469 | printV("sub_listen error: $e $s"); |
| 3470 | }); |
| 3471 | })); |
| 3472 | } |
| 3473 | |
| 3474 | Future<List<Map<String, dynamic>>> fetchBalancesBatch( |
| 3475 | List<BitcoinAddressRecord> addresses, |
| 3476 | ) async { |
| 3477 | final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList(); |
| 3478 | |
| 3479 | if (scriptHashes.isEmpty) { |
| 3480 | return <Map<String, dynamic>>[]; |
| 3481 | } |
| 3482 | |
| 3483 | try { |
| 3484 | final balancesByScriptHash = await _processChunksToMap<String, String, Map<String, dynamic>>( |
| 3485 | items: scriptHashes, |
| 3486 | chunkSize: addressHistoryChunkSize, |
| 3487 | processChunk: _getBalanceBatch, |
| 3488 | ); |
| 3489 | |
| 3490 | final balances = scriptHashes |
| 3491 | .map((scriptHash) => balancesByScriptHash[scriptHash] ?? <String, dynamic>{}) |
| 3492 | .toList(); |
| 3493 | |
| 3494 | final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null); |
| 3495 | if (hasMissingBalance) { |
| 3496 | printV('fetchBalancesBatch returned missing balances, falling back to regular flow'); |
| 3497 | return fetchBalancesRegular(addresses); |
| 3498 | } |
| 3499 | |
| 3500 | return balances; |
| 3501 | } catch (e) { |
| 3502 | printV('fetchBalancesBatch failed, falling back to regular flow: $e'); |
| 3503 | return fetchBalancesRegular(addresses); |
| 3504 | } |
| 3505 | } |
| 3506 | |
| 3507 | Future<List<Map<String, dynamic>>> fetchBalancesRegular( |
| 3508 | List<BitcoinAddressRecord> addresses, |
| 3509 | ) async { |
| 3510 | final balanceFutures = <Future<Map<String, dynamic>>>[]; |
| 3511 | |
| 3512 | for (final address in addresses) { |
| 3513 | final sh = address.getScriptHash(network); |
| 3514 | balanceFutures.add(electrumClient.getBalance(sh)); |
| 3515 | } |
| 3516 | |
| 3517 | return Future.wait(balanceFutures); |
| 3518 | } |
| 3519 | |
| 3520 | Future<ElectrumBalance> fetchBalances() async { |
| 3521 | final addresses = walletAddresses.allAddresses |
| 3522 | .where((address) => address.address.isNotEmpty) |
| 3523 | .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) |
| 3524 | .toList(); |
| 3525 | |
| 3526 | final balances = shouldUseBatchFetching |
| 3527 | ? await fetchBalancesBatch(addresses) |
| 3528 | : await fetchBalancesRegular(addresses); |
| 3529 | |
| 3530 | printV( |
| 3531 | 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); |
| 3532 | |
| 3533 | var totalFrozen = 0; |
| 3534 | var totalConfirmed = 0; |
| 3535 | var totalUnconfirmed = 0; |
| 3536 | |
| 3537 | if (hasSilentPaymentsScanning) { |
| 3538 | // Add values from unspent coins that are not fetched by the address list |
| 3539 | // i.e. scanned silent payments |
| 3540 | transactionHistory.transactions.values.forEach((tx) { |
| 3541 | if (tx.unspents != null) { |
| 3542 | tx.unspents!.forEach((unspent) { |
| 3543 | if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { |
| 3544 | if (unspent.isFrozen) totalFrozen += unspent.value; |
| 3545 | totalConfirmed += unspent.value; |
| 3546 | } |
| 3547 | }); |
| 3548 | } |
| 3549 | }); |
| 3550 | } |
| 3551 | |
| 3552 | unspentCoinsInfo.values.forEach((info) { |
| 3553 | unspentCoins.forEach((element) { |
| 3554 | if (element.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) return; |
| 3555 | |
| 3556 | if (element.hash == info.hash && |
| 3557 | element.vout == info.vout && |
| 3558 | element.bitcoinAddressRecord.address == info.address && |
| 3559 | element.value == info.value) { |
| 3560 | if (info.isFrozen) { |
| 3561 | totalFrozen += element.value; |
| 3562 | } |
| 3563 | } |
| 3564 | }); |
| 3565 | }); |
| 3566 | |
| 3567 | if (balances.isNotEmpty && balances.first['confirmed'] == null) { |
| 3568 | // if we got null balance responses from the server, set our connection status to lost and return our last known balance: |
| 3569 | printV("got null balance responses from the server, setting connection status to lost"); |
| 3570 | syncStatus = LostConnectionSyncStatus(); |
| 3571 | return balance[currency] ?? |
| 3572 | ElectrumBalance( |
| 3573 | confirmed: Money.zero(currency), |
| 3574 | unconfirmed: Money.zero(currency), |
| 3575 | frozen: Money.zero(currency), |
| 3576 | ); |
| 3577 | } |
| 3578 | |
| 3579 | for (var i = 0; i < balances.length; i++) { |
| 3580 | final addressRecord = addresses[i]; |
| 3581 | final balance = balances[i]; |
| 3582 | final confirmed = balance['confirmed'] as int? ?? 0; |
| 3583 | final unconfirmed = balance['unconfirmed'] as int? ?? 0; |
| 3584 | totalConfirmed += confirmed; |
| 3585 | totalUnconfirmed += unconfirmed; |
| 3586 | |
| 3587 | addressRecord.balance = confirmed + unconfirmed; |
| 3588 | if (confirmed > 0 || unconfirmed > 0) { |
| 3589 | addressRecord.setAsUsed(); |
| 3590 | walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); |
| 3591 | } |
| 3592 | } |
| 3593 | |
| 3594 | return ElectrumBalance( |
| 3595 | confirmed: Money.fromInt(totalConfirmed, currency), |
| 3596 | unconfirmed: Money.fromInt(totalUnconfirmed, currency), |
| 3597 | frozen: Money.fromInt(totalFrozen, currency), |
| 3598 | ); |
| 3599 | } |
| 3600 | |
| 3601 | Future<void> updateBalance() async { |
| 3602 | printV("updateBalance() called!"); |
| 3603 | balance[currency] = await fetchBalances(); |
| 3604 | await save(); |
| 3605 | } |
| 3606 | |
| 3607 | @override |
| 3608 | Future<bool> checkNodeHealth() async { |
| 3609 | try { |
| 3610 | final addresses = walletAddresses.allAddresses |
| 3611 | .where( |
| 3612 | (address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) |
| 3613 | .toList(); |
| 3614 | |
| 3615 | if (addresses.isEmpty) { |
| 3616 | return false; |
| 3617 | } |
| 3618 | |
| 3619 | final firstAddress = addresses.first; |
| 3620 | final sh = firstAddress.getScriptHash(network); |
| 3621 | await electrumClient.getBalance(sh, throwOnError: true); |
| 3622 | return true; |
| 3623 | } catch (e) { |
| 3624 | return false; |
| 3625 | } |
| 3626 | } |
| 3627 | |
| 3628 | @override |
| 3629 | void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; |
| 3630 | |
| 3631 | @override |
| 3632 | Future<String> signMessage(String message, {String? address = null}) async { |
| 3633 | final addressRecord = address != null |
| 3634 | ? walletAddresses.allAddresses.firstWhereOrNull((addr) => addr.address == address) |
| 3635 | : null; |
| 3636 | |
| 3637 | if (addressRecord != null && addressRecord.type == SegwitAddresType.p2tr) { |
| 3638 | throw UnsupportedError("Cannot sign message with Taproot address"); |
| 3639 | } |
| 3640 | |
| 3641 | final hd = addressRecord != null |
| 3642 | ? _hdFor(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) |
| 3643 | : mainHd; |
| 3644 | |
| 3645 | final priv = ECPrivate.fromHex(hd.privateKey.privKey.toHex()); |
| 3646 | |
| 3647 | String messagePrefix = '\x18Bitcoin Signed Message:\n'; |
| 3648 | final hexEncoded = priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix); |
| 3649 | final decodedSig = hex.decode(hexEncoded); |
| 3650 | return base64Encode(decodedSig); |
| 3651 | } |
| 3652 | |
| 3653 | void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) { |
| 3654 | if (this is! LitecoinWallet) return; |
| 3655 | |
| 3656 | // if we have a peg out transaction with the same value |
| 3657 | // that matches this received transaction, mark it as being from a peg out: |
| 3658 | for (final tx2 in transactionHistory.transactions.values) { |
| 3659 | final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); |
| 3660 | // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other |
| 3661 | if (tx2.additionalInfo["isPegOut"] == true && tx2.amount == tx.amount && heightDiff <= 5) { |
| 3662 | tx.additionalInfo["fromPegOut"] = true; |
| 3663 | } |
| 3664 | } |
| 3665 | } |
| 3666 | |
| 3667 | Future<void> checkIfBatchSupported() async { |
| 3668 | if (_isBatchSupported != null) { |
| 3669 | printV('[BATCH_TEST] Already checked: $_isBatchSupported'); |
| 3670 | return; |
| 3671 | } |
| 3672 | |
| 3673 | final hashes = publicScriptHashes.take(batchTestHashesCount).toList(); |
| 3674 | |
| 3675 | if (hashes.length < batchTestHashesCount) { |
| 3676 | _isBatchSupported = false; |
| 3677 | printV('[BATCH_TEST] Failed: not enough script hashes'); |
| 3678 | return; |
| 3679 | } |
| 3680 | |
| 3681 | try { |
| 3682 | final paramsList = hashes.map((hash) => <Object>[hash]).toList(); |
| 3683 | |
| 3684 | printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms'); |
| 3685 | |
| 3686 | final result = await electrumClient.callBatchWithTimeout( |
| 3687 | method: 'blockchain.scripthash.get_history', |
| 3688 | paramsList: paramsList, |
| 3689 | timeout: batchTestTimeoutMs, |
| 3690 | ); |
| 3691 | |
| 3692 | final hasError = result.any((item) => |
| 3693 | item is Map<String, dynamic> && item.containsKey('error') && item['error'] != null); |
| 3694 | |
| 3695 | if (hasError) { |
| 3696 | _isBatchSupported = false; |
| 3697 | printV('[BATCH_TEST] Result: supported=false (server returned error)'); |
| 3698 | return; |
| 3699 | } |
| 3700 | |
| 3701 | _isBatchSupported = true; |
| 3702 | printV('[BATCH_TEST] Result: supported=true'); |
| 3703 | } on electrum.RequestFailedTimeoutException catch (e) { |
| 3704 | _isBatchSupported = false; |
| 3705 | printV('[BATCH_TEST] Timeout: $e'); |
| 3706 | } catch (e) { |
| 3707 | _isBatchSupported = false; |
| 3708 | printV('[BATCH_TEST] Exception: $e'); |
| 3709 | } |
| 3710 | } |
| 3711 | |
| 3712 | @override |
| 3713 | Future<bool> verifyMessage(String message, String signature, {String? address = null}) async { |
| 3714 | if (address == null) { |
| 3715 | return false; |
| 3716 | } |
| 3717 | |
| 3718 | List<int> sigDecodedBytes = []; |
| 3719 | |
| 3720 | if (signature.endsWith('=')) { |
| 3721 | sigDecodedBytes = base64.decode(signature); |
| 3722 | } else { |
| 3723 | sigDecodedBytes = hex.decode(signature); |
| 3724 | } |
| 3725 | |
| 3726 | if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) { |
| 3727 | throw ArgumentException( |
| 3728 | "signature must be 64 bytes without recover-id or 65 bytes with recover-id"); |
| 3729 | } |
| 3730 | |
| 3731 | String messagePrefix = '\x18Bitcoin Signed Message:\n'; |
| 3732 | final messageHash = QuickCrypto.sha256Hash( |
| 3733 | BitcoinSignerUtils.magicMessage(utf8.encode(message), messagePrefix)); |
| 3734 | |
| 3735 | List<int> correctSignature = |
| 3736 | sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); |
| 3737 | List<int> rBytes = correctSignature.sublist(0, 32); |
| 3738 | List<int> sBytes = correctSignature.sublist(32); |
| 3739 | final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); |
| 3740 | |
| 3741 | List<int> possibleRecoverIds = [0, 1]; |
| 3742 | |
| 3743 | final baseAddress = RegexUtils.addressTypeFromStr(address, network); |
| 3744 | |
| 3745 | for (int recoveryId in possibleRecoverIds) { |
| 3746 | final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); |
| 3747 | |
| 3748 | final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); |
| 3749 | |
| 3750 | String? recoveredAddress; |
| 3751 | |
| 3752 | if (baseAddress is P2pkAddress) { |
| 3753 | recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network); |
| 3754 | } else if (baseAddress is P2pkhAddress) { |
| 3755 | recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network); |
| 3756 | } else if (baseAddress is P2wshAddress) { |
| 3757 | recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network); |
| 3758 | } else if (baseAddress is P2wpkhAddress) { |
| 3759 | recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network); |
| 3760 | } |
| 3761 | |
| 3762 | if (recoveredAddress == address) { |
| 3763 | return true; |
| 3764 | } |
| 3765 | } |
| 3766 | |
| 3767 | return false; |
| 3768 | } |
| 3769 | |
| 3770 | Future<void> _setInitialHeight() async { |
| 3771 | if (_chainTipUpdateSubject != null) return; |
| 3772 | |
| 3773 | currentChainTip = await getUpdatedChainTip(); |
| 3774 | |
| 3775 | if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) { |
| 3776 | await walletInfo.updateRestoreHeight(currentChainTip!); |
| 3777 | } |
| 3778 | |
| 3779 | _chainTipUpdateSubject = electrumClient.chainTipSubscribe(); |
| 3780 | _chainTipUpdateSubject?.listen((e) async { |
| 3781 | final event = e as Map<String, dynamic>; |
| 3782 | final height = int.tryParse(event['height'].toString()); |
| 3783 | |
| 3784 | if (height != null) { |
| 3785 | currentChainTip = height; |
| 3786 | |
| 3787 | if (alwaysScan == true && syncStatus is SyncedSyncStatus) { |
| 3788 | _setListeners(walletInfo.restoreHeight); |
| 3789 | } |
| 3790 | } |
| 3791 | }); |
| 3792 | } |
| 3793 | |
| 3794 | static String _hardenedDerivationPath(String derivationPath) => |
| 3795 | derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1); |
| 3796 | |
| 3797 | @action |
| 3798 | void _onConnectionStatusChange(electrum.ConnectionStatus status) { |
| 3799 | switch (status) { |
| 3800 | case electrum.ConnectionStatus.connected: |
| 3801 | if (syncStatus is NotConnectedSyncStatus || |
| 3802 | syncStatus is LostConnectionSyncStatus || |
| 3803 | syncStatus is ConnectingSyncStatus) { |
| 3804 | syncStatus = ConnectedSyncStatus(); |
| 3805 | } |
| 3806 | |
| 3807 | break; |
| 3808 | case electrum.ConnectionStatus.disconnected: |
| 3809 | // Always show disconnected status when connection is lost, regardless of current sync state |
| 3810 | if (syncStatus is! NotConnectedSyncStatus) { |
| 3811 | syncStatus = NotConnectedSyncStatus(); |
| 3812 | } |
| 3813 | break; |
| 3814 | case electrum.ConnectionStatus.failed: |
| 3815 | if (syncStatus is! LostConnectionSyncStatus) { |
| 3816 | syncStatus = LostConnectionSyncStatus(); |
| 3817 | } |
| 3818 | break; |
| 3819 | case electrum.ConnectionStatus.connecting: |
| 3820 | if (syncStatus is! ConnectingSyncStatus) { |
| 3821 | syncStatus = ConnectingSyncStatus(); |
| 3822 | } |
| 3823 | break; |
| 3824 | } |
| 3825 | } |
| 3826 | |
| 3827 | void _syncStatusReaction(SyncStatus syncStatus) async { |
| 3828 | printV("SYNC_STATUS_CHANGE: ${syncStatus}"); |
| 3829 | if (syncStatus is SyncingSyncStatus) { |
| 3830 | return; |
| 3831 | } |
| 3832 | |
| 3833 | if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus) { |
| 3834 | // Needs to re-subscribe to all scripthashes when reconnected |
| 3835 | _scripthashesUpdateSubject = {}; |
| 3836 | |
| 3837 | if (_isTryingToConnect) return; |
| 3838 | |
| 3839 | _isTryingToConnect = true; |
| 3840 | |
| 3841 | Timer(Duration(seconds: 5), () { |
| 3842 | if (this.syncStatus is NotConnectedSyncStatus || |
| 3843 | this.syncStatus is LostConnectionSyncStatus) { |
| 3844 | this.electrumClient.connectToUri( |
| 3845 | node!.uri, |
| 3846 | useSSL: node!.useSSL ?? false, |
| 3847 | ); |
| 3848 | } |
| 3849 | _isTryingToConnect = false; |
| 3850 | }); |
| 3851 | } |
| 3852 | |
| 3853 | // Message is shown on the UI for 3 seconds, revert to synced |
| 3854 | if (syncStatus is SyncedTipSyncStatus) { |
| 3855 | Timer(Duration(seconds: 3), () { |
| 3856 | if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); |
| 3857 | }); |
| 3858 | } |
| 3859 | } |
| 3860 | |
| 3861 | void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) { |
| 3862 | tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList(); |
| 3863 | |
| 3864 | if (tx.inputAddresses == null || |
| 3865 | tx.inputAddresses!.isEmpty || |
| 3866 | tx.outputAddresses == null || |
| 3867 | tx.outputAddresses!.isEmpty) { |
| 3868 | List<String> inputAddresses = []; |
| 3869 | List<String> outputAddresses = []; |
| 3870 | |
| 3871 | for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { |
| 3872 | final input = bundle.originalTransaction.inputs[i]; |
| 3873 | final inputTransaction = bundle.ins[i]; |
| 3874 | if (inputTransaction == null) continue; |
| 3875 | final vout = input.txIndex; |
| 3876 | final outTransaction = inputTransaction.outputs[vout]; |
| 3877 | final address = addressFromOutputScript(outTransaction.scriptPubKey, network); |
| 3878 | |
| 3879 | if (address.isNotEmpty) inputAddresses.add(address); |
| 3880 | } |
| 3881 | |
| 3882 | for (int i = 0; i < bundle.originalTransaction.outputs.length; i++) { |
| 3883 | final out = bundle.originalTransaction.outputs[i]; |
| 3884 | final address = addressFromOutputScript(out.scriptPubKey, network); |
| 3885 | |
| 3886 | if (address.isNotEmpty) outputAddresses.add(address); |
| 3887 | |
| 3888 | // Check if the script contains OP_RETURN |
| 3889 | final script = out.scriptPubKey.script; |
| 3890 | if (script.contains('OP_RETURN')) { |
| 3891 | final index = script.indexOf('OP_RETURN'); |
| 3892 | if (index + 1 <= script.length) { |
| 3893 | try { |
| 3894 | final opReturnData = script[index + 1].toString(); |
| 3895 | final decodedString = utf8.decode(HEX.decode(opReturnData)); |
| 3896 | outputAddresses.add('OP_RETURN:$decodedString'); |
| 3897 | } catch (_) { |
| 3898 | outputAddresses.add('OP_RETURN:'); |
| 3899 | } |
| 3900 | } |
| 3901 | } |
| 3902 | } |
| 3903 | tx.inputAddresses = inputAddresses; |
| 3904 | tx.outputAddresses = outputAddresses; |
| 3905 | |
| 3906 | transactionHistory.addOne(tx); |
| 3907 | } |
| 3908 | } |
| 3909 | |
| 3910 | /// Checks the health of the socket connection |
| 3911 | /// and triggers a full reconnection if needed |
| 3912 | @override |
| 3913 | Future<bool> checkSocketHealth() async { |
| 3914 | try { |
| 3915 | SocketHealthLogger().logHealthCheck( |
| 3916 | walletType: type, |
| 3917 | walletName: name, |
| 3918 | syncStatus: syncStatus.toString(), |
| 3919 | wasReconnected: false, |
| 3920 | trigger: 'socket_health_check_start', |
| 3921 | ); |
| 3922 | |
| 3923 | if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) { |
| 3924 | if (!electrumClient.isConnected) { |
| 3925 | SocketHealthLogger().logHealthCheck( |
| 3926 | walletType: type, |
| 3927 | walletName: name, |
| 3928 | isHealthy: false, |
| 3929 | syncStatus: syncStatus.toString(), |
| 3930 | wasReconnected: false, |
| 3931 | trigger: 'socket_health_check_socket_not_connected', |
| 3932 | ); |
| 3933 | } |
| 3934 | |
| 3935 | if (!electrumClient.isInternalStateConsistent) { |
| 3936 | SocketHealthLogger().logHealthCheck( |
| 3937 | walletType: type, |
| 3938 | walletName: name, |
| 3939 | isHealthy: false, |
| 3940 | syncStatus: syncStatus.toString(), |
| 3941 | wasReconnected: false, |
| 3942 | trigger: 'socket_health_check_internal_state_inconsistent', |
| 3943 | ); |
| 3944 | } |
| 3945 | |
| 3946 | await _performFullReconnection(); |
| 3947 | |
| 3948 | SocketHealthLogger().logHealthCheck( |
| 3949 | walletType: type, |
| 3950 | walletName: name, |
| 3951 | isHealthy: true, |
| 3952 | syncStatus: syncStatus.toString(), |
| 3953 | wasReconnected: true, |
| 3954 | trigger: |
| 3955 | 'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent', |
| 3956 | ); |
| 3957 | |
| 3958 | return true; |
| 3959 | } |
| 3960 | |
| 3961 | // Make a call to the server to check if the connection is healthy |
| 3962 | // If the call fails, we need to reconnect |
| 3963 | try { |
| 3964 | final result = await electrumClient.call( |
| 3965 | method: 'server.version', |
| 3966 | params: ['', '1.4'], |
| 3967 | ); |
| 3968 | |
| 3969 | if (result == null) { |
| 3970 | throw Exception('Call mechanism test returned null'); |
| 3971 | } |
| 3972 | |
| 3973 | SocketHealthLogger().logHealthCheck( |
| 3974 | walletType: type, |
| 3975 | walletName: name, |
| 3976 | isHealthy: true, |
| 3977 | syncStatus: syncStatus.toString(), |
| 3978 | wasReconnected: false, |
| 3979 | trigger: 'socket_health_check_server_state_ok', |
| 3980 | ); |
| 3981 | |
| 3982 | return true; |
| 3983 | } catch (e) { |
| 3984 | SocketHealthLogger().logHealthCheck( |
| 3985 | walletType: type, |
| 3986 | walletName: name, |
| 3987 | isHealthy: false, |
| 3988 | error: e.toString(), |
| 3989 | syncStatus: syncStatus.toString(), |
| 3990 | wasReconnected: false, |
| 3991 | trigger: 'socket_health_check_server_state_failed', |
| 3992 | ); |
| 3993 | |
| 3994 | await _performFullReconnection(); |
| 3995 | |
| 3996 | SocketHealthLogger().logHealthCheck( |
| 3997 | walletType: type, |
| 3998 | walletName: name, |
| 3999 | isHealthy: true, |
| 4000 | syncStatus: syncStatus.toString(), |
| 4001 | wasReconnected: true, |
| 4002 | trigger: 'socket_health_check_reconnection_success_for_server_state_failed', |
| 4003 | ); |
| 4004 | |
| 4005 | return true; |
| 4006 | } |
| 4007 | } catch (e) { |
| 4008 | return false; |
| 4009 | } |
| 4010 | } |
| 4011 | |
| 4012 | Future<void> _performFullReconnection() async { |
| 4013 | try { |
| 4014 | SocketHealthLogger().logHealthCheck( |
| 4015 | walletType: type, |
| 4016 | walletName: name, |
| 4017 | syncStatus: syncStatus.toString(), |
| 4018 | wasReconnected: true, |
| 4019 | trigger: 'full_reconnection_start', |
| 4020 | ); |
| 4021 | |
| 4022 | await _receiveStream?.cancel(); |
| 4023 | |
| 4024 | await electrumClient.close(); |
| 4025 | |
| 4026 | if (node != null) { |
| 4027 | electrumClient.onConnectionStatusChange = _onConnectionStatusChange; |
| 4028 | |
| 4029 | await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL); |
| 4030 | |
| 4031 | await startSync(); |
| 4032 | |
| 4033 | SocketHealthLogger().logHealthCheck( |
| 4034 | walletType: type, |
| 4035 | walletName: name, |
| 4036 | isHealthy: true, |
| 4037 | syncStatus: syncStatus.toString(), |
| 4038 | wasReconnected: true, |
| 4039 | trigger: 'full_reconnection_success', |
| 4040 | ); |
| 4041 | } |
| 4042 | } catch (e) { |
| 4043 | SocketHealthLogger().logHealthCheck( |
| 4044 | walletType: type, |
| 4045 | walletName: name, |
| 4046 | isHealthy: false, |
| 4047 | error: e.toString(), |
| 4048 | syncStatus: syncStatus.toString(), |
| 4049 | wasReconnected: false, |
| 4050 | trigger: 'full_reconnection_failed', |
| 4051 | ); |
| 4052 | |
| 4053 | syncStatus = FailedSyncStatus(); |
| 4054 | } |
| 4055 | } |
| 4056 | |
| 4057 | Bip32Slip10Secp256k1 _hdFor({required BaseBitcoinAddressRecord record}) { |
| 4058 | final addrType = record.type; |
| 4059 | |
| 4060 | if (record.isLegacyDerivation) { |
| 4061 | if (record.isHidden) { |
| 4062 | return walletAddresses.legacySideHd; |
| 4063 | } else { |
| 4064 | return walletAddresses.legacyMainHd; |
| 4065 | } |
| 4066 | } |
| 4067 | |
| 4068 | if (record.isHidden) { |
| 4069 | return sideHdByType[addrType] ?? sideHd; |
| 4070 | } else { |
| 4071 | return mainHdByType[addrType] ?? mainHd; |
| 4072 | } |
| 4073 | } |
| 4074 | } |
| 4075 | |
| 4076 | class ScanNode { |
| 4077 | final Uri uri; |
| 4078 | final bool? useSSL; |
| 4079 | |
| 4080 | ScanNode(this.uri, this.useSSL); |
| 4081 | } |
| 4082 | |
| 4083 | class ScanData { |
| 4084 | final SendPort sendPort; |
| 4085 | final SilentPaymentOwner silentAddress; |
| 4086 | final Bip32Slip10Secp256k1 masterHD; |
| 4087 | final int height; |
| 4088 | final ScanNode? node; |
| 4089 | final BasedUtxoNetwork network; |
| 4090 | final int chainTip; |
| 4091 | final electrum.ElectrumClient electrumClient; |
| 4092 | final List<String> transactionHistoryIds; |
| 4093 | final Map<String, String> labels; |
| 4094 | final List<int> labelIndexes; |
| 4095 | final bool isSingleScan; |
| 4096 | final String debugLogPath; |
| 4097 | final List<int>? rescanHeights; |
| 4098 | |
| 4099 | ScanData({ |
| 4100 | required this.sendPort, |
| 4101 | required this.silentAddress, |
| 4102 | required this.masterHD, |
| 4103 | required this.height, |
| 4104 | required this.node, |
| 4105 | required this.network, |
| 4106 | required this.chainTip, |
| 4107 | required this.electrumClient, |
| 4108 | required this.transactionHistoryIds, |
| 4109 | required this.labels, |
| 4110 | required this.labelIndexes, |
| 4111 | required this.isSingleScan, |
| 4112 | required this.debugLogPath, |
| 4113 | required this.rescanHeights, |
| 4114 | }); |
| 4115 | |
| 4116 | factory ScanData.fromHeight(ScanData scanData, int newHeight) { |
| 4117 | return ScanData( |
| 4118 | sendPort: scanData.sendPort, |
| 4119 | silentAddress: scanData.silentAddress, |
| 4120 | masterHD: scanData.masterHD, |
| 4121 | height: newHeight, |
| 4122 | node: scanData.node, |
| 4123 | network: scanData.network, |
| 4124 | chainTip: scanData.chainTip, |
| 4125 | transactionHistoryIds: scanData.transactionHistoryIds, |
| 4126 | electrumClient: scanData.electrumClient, |
| 4127 | labels: scanData.labels, |
| 4128 | labelIndexes: scanData.labelIndexes, |
| 4129 | isSingleScan: scanData.isSingleScan, |
| 4130 | debugLogPath: scanData.debugLogPath, |
| 4131 | rescanHeights: scanData.rescanHeights, |
| 4132 | ); |
| 4133 | } |
| 4134 | } |
| 4135 | |
| 4136 | class SyncResponse { |
| 4137 | final int height; |
| 4138 | final SyncStatus syncStatus; |
| 4139 | |
| 4140 | SyncResponse(this.height, this.syncStatus); |
| 4141 | } |
| 4142 | |
| 4143 | Future<void> _handleScanSilentPayments(ScanData scanData) async { |
| 4144 | final shouldUpdateSyncStatus = scanData.rescanHeights == null || scanData.rescanHeights!.isEmpty; |
| 4145 | final hasForcedRescanHeights = !shouldUpdateSyncStatus; |
| 4146 | CakeTor.instance = await CakeTorInstance.getInstance(); |
| 4147 | |
| 4148 | var node = scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"); |
| 4149 | |
| 4150 | void log(String message, LogLevel level) { |
| 4151 | printV("[Scanning] $message", file: scanData.debugLogPath, level: level); |
| 4152 | } |
| 4153 | |
| 4154 | try { |
| 4155 | // if (scanData.shouldSwitchNodes) { |
| 4156 | var scanningClient = await ElectrumProvider.connect( |
| 4157 | ElectrumTCPService.connect(node), |
| 4158 | ); |
| 4159 | // } |
| 4160 | |
| 4161 | log("connected to ${node.toString()}", LogLevel.info); |
| 4162 | |
| 4163 | final receivers = [ |
| 4164 | Receiver( |
| 4165 | scanData.silentAddress.b_scan.toHex(), |
| 4166 | scanData.silentAddress.B_spend.toHex(), |
| 4167 | scanData.network == BitcoinNetwork.testnet, |
| 4168 | scanData.labelIndexes, |
| 4169 | scanData.labelIndexes.length, |
| 4170 | ), |
| 4171 | Receiver( |
| 4172 | scanData.masterHD.derivePath(SILENT_PAYMENTS_SCAN_PATH_TESTNET).privateKey.toHex(), |
| 4173 | scanData.masterHD.derivePath(SILENT_PAYMENTS_SPEND_PATH_TESTNET).publicKey.toHex(), |
| 4174 | scanData.network == BitcoinNetwork.testnet, |
| 4175 | scanData.labelIndexes, |
| 4176 | scanData.labelIndexes.length, |
| 4177 | ) |
| 4178 | ]; |
| 4179 | |
| 4180 | log( |
| 4181 | "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}", |
| 4182 | LogLevel.info, |
| 4183 | ); |
| 4184 | log( |
| 4185 | "using receiver: b_scan: ${receivers[1].bScan}, b_spend: ${receivers[1].BSpend}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}", |
| 4186 | LogLevel.info, |
| 4187 | ); |
| 4188 | |
| 4189 | void scan(int syncHeight, bool isSingleScan) async { |
| 4190 | int initialSyncHeight = syncHeight; |
| 4191 | |
| 4192 | int getCountToScanPerRequest(int syncHeight) { |
| 4193 | if (isSingleScan) { |
| 4194 | return 1; |
| 4195 | } |
| 4196 | |
| 4197 | final amountLeft = scanData.chainTip - syncHeight + 1; |
| 4198 | return amountLeft; |
| 4199 | } |
| 4200 | |
| 4201 | // Initial status UI update, send how many blocks in total to scan |
| 4202 | if (shouldUpdateSyncStatus) |
| 4203 | scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); |
| 4204 | |
| 4205 | final req = ElectrumTweaksSubscribe( |
| 4206 | height: syncHeight, |
| 4207 | count: getCountToScanPerRequest(syncHeight), |
| 4208 | historicalMode: hasForcedRescanHeights, |
| 4209 | ); |
| 4210 | |
| 4211 | var _scanningStream = await scanningClient.subscribe(req); |
| 4212 | |
| 4213 | log( |
| 4214 | "initial request: height: $syncHeight, count: ${getCountToScanPerRequest(syncHeight)}", |
| 4215 | LogLevel.info, |
| 4216 | ); |
| 4217 | |
| 4218 | void endScanningSuccesfully() { |
| 4219 | if (isSingleScan) { |
| 4220 | scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); |
| 4221 | } else { |
| 4222 | scanData.sendPort.send( |
| 4223 | SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)), |
| 4224 | ); |
| 4225 | } |
| 4226 | |
| 4227 | _scanningStream?.close(); |
| 4228 | _scanningStream = null; |
| 4229 | |
| 4230 | log( |
| 4231 | "ended: syncHeight: $syncHeight, chainTip: ${scanData.chainTip}, isSingleScan: ${isSingleScan}", |
| 4232 | LogLevel.info, |
| 4233 | ); |
| 4234 | } |
| 4235 | |
| 4236 | void listenFn(Map<String, dynamic> event, ElectrumTweaksSubscribe req) async { |
| 4237 | final response = req.onResponse(event); |
| 4238 | |
| 4239 | if (response == null || _scanningStream == null) { |
| 4240 | log( |
| 4241 | "ending: response = $response, stream = $_scanningStream", |
| 4242 | LogLevel.error, |
| 4243 | ); |
| 4244 | return; |
| 4245 | } |
| 4246 | |
| 4247 | // is success or error msg |
| 4248 | final noData = response.message != null; |
| 4249 | |
| 4250 | if (noData) { |
| 4251 | if (isSingleScan) { |
| 4252 | log("ending: noData and isSingleScan", LogLevel.info); |
| 4253 | |
| 4254 | endScanningSuccesfully(); |
| 4255 | return; |
| 4256 | } |
| 4257 | |
| 4258 | // re-subscribe to continue receiving messages, starting from the next unscanned height |
| 4259 | final nextHeight = syncHeight + 1; |
| 4260 | |
| 4261 | if (nextHeight <= scanData.chainTip) { |
| 4262 | log( |
| 4263 | "resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", |
| 4264 | LogLevel.info, |
| 4265 | ); |
| 4266 | |
| 4267 | final nextStream = scanningClient.subscribe( |
| 4268 | ElectrumTweaksSubscribe( |
| 4269 | height: nextHeight, |
| 4270 | count: getCountToScanPerRequest(nextHeight), |
| 4271 | historicalMode: hasForcedRescanHeights, |
| 4272 | ), |
| 4273 | ); |
| 4274 | |
| 4275 | if (nextStream != null) { |
| 4276 | nextStream.listen((event) => listenFn(event, req)); |
| 4277 | } else { |
| 4278 | if (shouldUpdateSyncStatus) |
| 4279 | scanData.sendPort.send( |
| 4280 | SyncResponse(scanData.height, LostConnectionSyncStatus()), |
| 4281 | ); |
| 4282 | } |
| 4283 | } |
| 4284 | |
| 4285 | log( |
| 4286 | "ending: resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", |
| 4287 | LogLevel.info, |
| 4288 | ); |
| 4289 | return; |
| 4290 | } |
| 4291 | |
| 4292 | final tweakHeight = response.block; |
| 4293 | |
| 4294 | // Continuous status UI update, send how many blocks left to scan |
| 4295 | final syncingStatus = isSingleScan |
| 4296 | ? SyncingSyncStatus(1, 0) |
| 4297 | : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight); |
| 4298 | |
| 4299 | if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); |
| 4300 | |
| 4301 | try { |
| 4302 | final blockTweaks = response.blockTweaks; |
| 4303 | |
| 4304 | var blockDate = DateTime.now(); |
| 4305 | bool isDateNow = true; |
| 4306 | |
| 4307 | for (final txid in blockTweaks.keys) { |
| 4308 | final tweakData = blockTweaks[txid]; |
| 4309 | final outputPubkeys = tweakData!.outputPubkeys; |
| 4310 | final tweak = tweakData.tweak; |
| 4311 | |
| 4312 | try { |
| 4313 | final addToWallet = <String, dynamic>{}; |
| 4314 | |
| 4315 | receivers.forEach((receiver) { |
| 4316 | final preparedList = outputPubkeys.keys.toList().map((e) => [e]).toList(); |
| 4317 | // NOTE: scanOutputs, from sp_scanner package, called from rust here |
| 4318 | final scanResult = scanOutputs(preparedList, tweak, receiver); |
| 4319 | |
| 4320 | if (scanResult.isEmpty) return; |
| 4321 | |
| 4322 | if (addToWallet[receiver.BSpend] == null) { |
| 4323 | addToWallet[receiver.BSpend] = scanResult; |
| 4324 | } else { |
| 4325 | addToWallet[receiver.BSpend].addAll(scanResult); |
| 4326 | } |
| 4327 | }); |
| 4328 | |
| 4329 | if (addToWallet.isEmpty) { |
| 4330 | // no results tx, continue to next tx |
| 4331 | continue; |
| 4332 | } |
| 4333 | |
| 4334 | log( |
| 4335 | "FOUND: addToWallet: ${addToWallet.length}, txid: $txid, tweak: $tweak, height: $tweakHeight", |
| 4336 | LogLevel.info, |
| 4337 | ); |
| 4338 | |
| 4339 | // Every tx in the block has the same date (the block date) |
| 4340 | // So, if blockDate exists, reuse |
| 4341 | if (isDateNow) { |
| 4342 | try { |
| 4343 | final rootURL = "https://cake.mempool.space"; |
| 4344 | final tweakBlockHash = await ProxyWrapper() |
| 4345 | .get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight")) |
| 4346 | .timeout(Duration(seconds: 15)); |
| 4347 | final blockResponse = await ProxyWrapper() |
| 4348 | .get(clearnetUri: Uri.parse("$rootURL/api/block/${tweakBlockHash.body}")) |
| 4349 | .timeout(Duration(seconds: 15)); |
| 4350 | |
| 4351 | if (blockResponse.statusCode == 200 && |
| 4352 | blockResponse.body.isNotEmpty && |
| 4353 | jsonDecode(blockResponse.body)['timestamp'] != null) { |
| 4354 | blockDate = DateTime.fromMillisecondsSinceEpoch( |
| 4355 | int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()) * 1000, |
| 4356 | ); |
| 4357 | isDateNow = false; |
| 4358 | } |
| 4359 | } catch (e, stacktrace) { |
| 4360 | printV(stacktrace); |
| 4361 | printV(e.toString()); |
| 4362 | } |
| 4363 | } |
| 4364 | |
| 4365 | // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop |
| 4366 | final txInfo = ElectrumTransactionInfo( |
| 4367 | WalletType.bitcoin, |
| 4368 | id: txid, |
| 4369 | height: tweakHeight, |
| 4370 | amount: Money.zero(CryptoCurrency.btc), |
| 4371 | fee: Money.zero(CryptoCurrency.btc), |
| 4372 | direction: TransactionDirection.incoming, |
| 4373 | isReplaced: false, |
| 4374 | date: scanData.network == BitcoinNetwork.mainnet |
| 4375 | ? (isDateNow ? getDateByBitcoinHeight(tweakHeight) : blockDate) |
| 4376 | : DateTime.now(), |
| 4377 | confirmations: scanData.chainTip - tweakHeight + 1, |
| 4378 | isReceivedSilentPayment: true, |
| 4379 | isPending: false, |
| 4380 | unspents: [], |
| 4381 | ); |
| 4382 | |
| 4383 | List<BitcoinUnspent> unspents = []; |
| 4384 | |
| 4385 | addToWallet.forEach((BSpend, scanResultPerLabel) { |
| 4386 | scanResultPerLabel.forEach((label, scanOutput) { |
| 4387 | final labelValue = label == "None" ? null : label.toString(); |
| 4388 | |
| 4389 | (scanOutput as Map<String, dynamic>).forEach((outputPubkey, tweak) { |
| 4390 | final t_k = tweak as String; |
| 4391 | |
| 4392 | final receivingOutputAddress = ECPublic.fromHex(outputPubkey) |
| 4393 | .toTaprootAddress(tweak: false) |
| 4394 | .toAddress(scanData.network); |
| 4395 | |
| 4396 | final matchingOutput = outputPubkeys[outputPubkey]!; |
| 4397 | final amount = matchingOutput.amount; |
| 4398 | final pos = matchingOutput.vout; |
| 4399 | final spent = matchingOutput.spendingInput; |
| 4400 | |
| 4401 | final matchingReceiver = |
| 4402 | receivers.indexWhere((receiver) => receiver.BSpend == BSpend); |
| 4403 | |
| 4404 | // final labelIndex = labelValue != null ? scanData.labels[label] : 0; |
| 4405 | // final balance = ElectrumBalance(); |
| 4406 | // balance.confirmed = amount; |
| 4407 | |
| 4408 | final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( |
| 4409 | receivingOutputAddress, |
| 4410 | index: 0, |
| 4411 | isHidden: false, |
| 4412 | isUsed: true, |
| 4413 | network: scanData.network, |
| 4414 | silentPaymentTweak: t_k, |
| 4415 | type: SegwitAddresType.p2tr, |
| 4416 | txCount: 1, |
| 4417 | balance: amount, |
| 4418 | spendDerivationPath: matchingReceiver == 0 |
| 4419 | ? SILENT_PAYMENTS_SPEND_PATH |
| 4420 | : SILENT_PAYMENTS_SPEND_PATH_TESTNET, |
| 4421 | ); |
| 4422 | |
| 4423 | final unspent = BitcoinSilentPaymentsUnspent( |
| 4424 | receivedAddressRecord, |
| 4425 | txid, |
| 4426 | amount, |
| 4427 | pos, |
| 4428 | silentPaymentTweak: t_k, |
| 4429 | silentPaymentLabel: labelValue, |
| 4430 | ); |
| 4431 | |
| 4432 | if (spent == null) { |
| 4433 | unspents.add(unspent); |
| 4434 | txInfo.unspents!.add(unspent); |
| 4435 | } |
| 4436 | |
| 4437 | txInfo.amount += Money.fromInt(unspent.value, txInfo.amount.currency); |
| 4438 | }); |
| 4439 | }); |
| 4440 | }); |
| 4441 | |
| 4442 | scanData.sendPort.send({txInfo.id: txInfo}); |
| 4443 | } catch (e, stacktrace) { |
| 4444 | if (shouldUpdateSyncStatus) |
| 4445 | scanData.sendPort.send( |
| 4446 | SyncResponse(syncHeight, LostConnectionSyncStatus()), |
| 4447 | ); |
| 4448 | |
| 4449 | log(stacktrace.toString(), LogLevel.error); |
| 4450 | log(e.toString(), LogLevel.error); |
| 4451 | return; |
| 4452 | } |
| 4453 | } |
| 4454 | } catch (e, stacktrace) { |
| 4455 | if (shouldUpdateSyncStatus) |
| 4456 | scanData.sendPort.send( |
| 4457 | SyncResponse(syncHeight, LostConnectionSyncStatus()), |
| 4458 | ); |
| 4459 | |
| 4460 | log(stacktrace.toString(), LogLevel.error); |
| 4461 | log(e.toString(), LogLevel.error); |
| 4462 | return; |
| 4463 | } |
| 4464 | |
| 4465 | syncHeight = tweakHeight; |
| 4466 | |
| 4467 | if ((tweakHeight >= scanData.chainTip) || isSingleScan) { |
| 4468 | endScanningSuccesfully(); |
| 4469 | } |
| 4470 | } |
| 4471 | |
| 4472 | _scanningStream?.listen((event) => listenFn(event, req)); |
| 4473 | } |
| 4474 | |
| 4475 | if (scanData.rescanHeights != null) { |
| 4476 | for (final height in scanData.rescanHeights!) { |
| 4477 | log("rescanning from height: $height", LogLevel.info); |
| 4478 | scan(height, true); |
| 4479 | } |
| 4480 | } else { |
| 4481 | scan(scanData.height, scanData.isSingleScan); |
| 4482 | } |
| 4483 | } catch (e) { |
| 4484 | log("Error in _handleScanSilentPayments: $e", LogLevel.error); |
| 4485 | if (shouldUpdateSyncStatus) |
| 4486 | scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus())); |
| 4487 | } |
| 4488 | } |
| 4489 | |
| 4490 | class EstimatedTxResult { |
| 4491 | EstimatedTxResult({ |
| 4492 | required this.utxos, |
| 4493 | required this.inputPrivKeyInfos, |
| 4494 | required this.publicKeys, |
| 4495 | required this.fee, |
| 4496 | required this.amount, |
| 4497 | required this.hasChange, |
| 4498 | required this.isSendAll, |
| 4499 | this.memo, |
| 4500 | required this.spendsSilentPayment, |
| 4501 | required this.spendsUnconfirmedTX, |
| 4502 | }); |
| 4503 | |
| 4504 | final List<UtxoWithAddress> utxos; |
| 4505 | final List<ECPrivateInfo> inputPrivKeyInfos; |
| 4506 | final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath |
| 4507 | final Money fee; |
| 4508 | final Money amount; |
| 4509 | final bool spendsSilentPayment; |
| 4510 | |
| 4511 | // final bool sendsToSilentPayment; |
| 4512 | final bool hasChange; |
| 4513 | final bool isSendAll; |
| 4514 | final String? memo; |
| 4515 | final bool spendsUnconfirmedTX; |
| 4516 | } |
| 4517 | |
| 4518 | class PublicKeyWithDerivationPath { |
| 4519 | const PublicKeyWithDerivationPath(this.publicKey, this.derivationPath); |
| 4520 | |
| 4521 | final String derivationPath; |
| 4522 | final String publicKey; |
| 4523 | } |
| 4524 | |
| 4525 | BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { |
| 4526 | if (type is P2pkhAddress) { |
| 4527 | return P2pkhAddressType.p2pkh; |
| 4528 | } else if (type is P2shAddress) { |
| 4529 | return P2shAddressType.p2wpkhInP2sh; |
| 4530 | } else if (type is P2wshAddress) { |
| 4531 | return SegwitAddresType.p2wsh; |
| 4532 | } else if (type is P2trAddress) { |
| 4533 | return SegwitAddresType.p2tr; |
| 4534 | } else if (type is MwebAddress) { |
| 4535 | return SegwitAddresType.mweb; |
| 4536 | } else if (type is SilentPaymentsAddresType) { |
| 4537 | return SilentPaymentsAddresType.p2sp; |
| 4538 | } else { |
| 4539 | return SegwitAddresType.p2wpkh; |
| 4540 | } |
| 4541 | } |
| 4542 | |
| 4543 | class UtxoDetails { |
| 4544 | final List<BitcoinUnspent> availableInputs; |
| 4545 | final List<BitcoinUnspent> unconfirmedCoins; |
| 4546 | final List<UtxoWithAddress> utxos; |
| 4547 | final List<Outpoint> vinOutpoints; |
| 4548 | final List<ECPrivateInfo> inputPrivKeyInfos; |
| 4549 | final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath |
| 4550 | final int allInputsAmount; |
| 4551 | final bool spendsSilentPayment; |
| 4552 | final bool spendsUnconfirmedTX; |
| 4553 | |
| 4554 | UtxoDetails({ |
| 4555 | required this.availableInputs, |
| 4556 | required this.unconfirmedCoins, |
| 4557 | required this.utxos, |
| 4558 | required this.vinOutpoints, |
| 4559 | required this.inputPrivKeyInfos, |
| 4560 | required this.publicKeys, |
| 4561 | required this.allInputsAmount, |
| 4562 | required this.spendsSilentPayment, |
| 4563 | required this.spendsUnconfirmedTX, |
| 4564 | }); |
| 4565 | } |