From 68a5821ad7c9f1eed101238b598a86714517fb00 Mon Sep 17 00:00:00 2001 From: rafael_xmr Date: Sat, 6 Sep 2025 20:34:17 -0300 Subject: [PATCH] Cw 1127 sp utxos not updated saved correctly (#2472) * fix: ETA for sp scan * fix: missing SP unspent when re-open * fix: address comments * feat: check sp unspents for spendingTx * feat: resolve comments * fix: always starts scanning * minor [skip ci] * fix stuck at 1 block remaining --------- Co-authored-by: Omar Hatem --- cw_bitcoin/lib/electrum_wallet.dart | 551 +++++++++++++++------------- cw_core/lib/sync_status.dart | 40 +- 2 files changed, 326 insertions(+), 265 deletions(-) diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 5427e824f..75f59bb89 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -340,19 +340,21 @@ abstract class ElectrumWalletBase } @action - Future _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async { + Future _setListeners(int height, + {int? chainTipParam, bool? doSingleScan, List? rescanHeights}) async { if (this is! BitcoinWallet) return; if (isHardwareWallet) return; if (seed?.isEmpty ?? true) return; final chainTip = chainTipParam ?? await getUpdatedChainTip(); + final shouldUpdateSyncStatus = rescanHeights == null || rescanHeights.isEmpty; if (chainTip == height) { syncStatus = SyncedSyncStatus(); return; } - syncStatus = AttemptingScanSyncStatus(); + if (shouldUpdateSyncStatus) syncStatus = AttemptingScanSyncStatus(); if (_isolate != null) { final runningIsolate = await _isolate!; @@ -383,6 +385,7 @@ abstract class ElectrumWalletBase .toList(), isSingleScan: doSingleScan ?? false, debugLogPath: debugLogPath, + rescanHeights: rescanHeights, ), ); @@ -406,6 +409,7 @@ abstract class ElectrumWalletBase existingTxInfo.isReceivedSilentPayment = tx.isReceivedSilentPayment; existingTxInfo.direction = tx.direction; existingTxInfo.isPending = tx.isPending; + existingTxInfo.unspents = tx.unspents; final newUnspents = tx.unspents! .where((unspent) => !(existingTxInfo.unspents?.any((element) => @@ -459,9 +463,9 @@ abstract class ElectrumWalletBase if (message.syncStatus is SyncingSyncStatus) { var status = message.syncStatus as SyncingSyncStatus; - syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc); + if (shouldUpdateSyncStatus) syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc); } else { - syncStatus = message.syncStatus; + if (shouldUpdateSyncStatus) syncStatus = message.syncStatus; } await walletInfo.updateRestoreHeight(message.height); @@ -491,6 +495,9 @@ abstract class ElectrumWalletBase ); } + DateTime? _lastSilentPaymentsScan; + static const Duration _silentPaymentsScanDelay = Duration(minutes: 1); + @action @override Future startSync() async { @@ -504,6 +511,30 @@ abstract class ElectrumWalletBase if (hasSilentPaymentsScanning) { silentPaymentsScanningActive = alwaysScan ?? false; await _setInitialHeight(); + + final now = DateTime.now(); + final shouldForceRescan = _lastSilentPaymentsScan == null || + now.difference(_lastSilentPaymentsScan!) >= _silentPaymentsScanDelay; + + // Timer prevents server failure and this infinite looping and requesting + if (shouldForceRescan) { + _lastSilentPaymentsScan = now; + + final rescanHeights = []; + + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspents != null && tx.unspents!.isNotEmpty) + for (final unspent in tx.unspents!) { + if (unspent.silentPaymentTweak != null && tx.height != null && tx.height! > 0) { + rescanHeights.add(tx.height!); + break; + } + } + }); + + if (rescanHeights.isNotEmpty) + _setListeners(walletInfo.restoreHeight, rescanHeights: rescanHeights); + } } await subscribeForUpdates(); @@ -519,7 +550,9 @@ abstract class ElectrumWalletBase if (alwaysScan == true) { setSilentPaymentsScanning(true); } else { - if (syncStatus is LostConnectionSyncStatus) return; + if (syncStatus is LostConnectionSyncStatus) { + return; + } syncStatus = SyncedSyncStatus(); } } catch (e, stacktrace) { @@ -1574,12 +1607,16 @@ abstract class ElectrumWalletBase } unspentCoins = updatedUnspentCoins; } else { - unspentCoins = handleFailedUtxoFetch( - failedCount: failedCount, - previousUnspentCoins: previousUnspentCoins, - updatedUnspentCoins: updatedUnspentCoins, - results: results, - ); + if (updatedUnspentCoins.isEmpty) { + unspentCoins = handleFailedUtxoFetch( + failedCount: failedCount, + previousUnspentCoins: previousUnspentCoins, + updatedUnspentCoins: updatedUnspentCoins, + results: results, + ); + } else { + unspentCoins = updatedUnspentCoins; + } } final currentWalletUnspentCoins = @@ -2882,6 +2919,7 @@ class ScanData { final List labelIndexes; final bool isSingleScan; final String debugLogPath; + final List? rescanHeights; ScanData({ required this.sendPort, @@ -2896,6 +2934,7 @@ class ScanData { required this.labelIndexes, required this.isSingleScan, required this.debugLogPath, + required this.rescanHeights, }); factory ScanData.fromHeight(ScanData scanData, int newHeight) { @@ -2912,6 +2951,7 @@ class ScanData { labelIndexes: scanData.labelIndexes, isSingleScan: scanData.isSingleScan, debugLogPath: scanData.debugLogPath, + rescanHeights: scanData.rescanHeights, ); } } @@ -2924,6 +2964,9 @@ class SyncResponse { } Future _handleScanSilentPayments(ScanData scanData) async { + final shouldUpdateSyncStatus = scanData.rescanHeights == null || scanData.rescanHeights!.isEmpty; + final hasForcedRescanHeights = !shouldUpdateSyncStatus; + var node = Uri.parse("tcp://electrs.cakewallet.com:50001"); void log(String message, LogLevel level) { @@ -2939,9 +2982,6 @@ Future _handleScanSilentPayments(ScanData scanData) async { log("connected to ${node.toString()}", LogLevel.info); - int syncHeight = scanData.height; - int initialSyncHeight = syncHeight; - final receiver = Receiver( scanData.silentAddress.b_scan.toHex(), scanData.silentAddress.B_spend.toHex(), @@ -2955,290 +2995,311 @@ Future _handleScanSilentPayments(ScanData scanData) async { LogLevel.info, ); - int getCountToScanPerRequest(int syncHeight) { - if (scanData.isSingleScan) { - return 1; + void scan(int syncHeight, bool isSingleScan) async { + int initialSyncHeight = syncHeight; + + int getCountToScanPerRequest(int syncHeight) { + if (isSingleScan) { + return 1; + } + + final amountLeft = scanData.chainTip - syncHeight + 1; + return amountLeft; } - final amountLeft = scanData.chainTip - syncHeight + 1; - return amountLeft; - } + // Initial status UI update, send how many blocks in total to scan + if (shouldUpdateSyncStatus) + scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); - // Initial status UI update, send how many blocks in total to scan - scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); + final req = ElectrumTweaksSubscribe( + height: syncHeight, + count: getCountToScanPerRequest(syncHeight), + historicalMode: hasForcedRescanHeights, + ); - final req = ElectrumTweaksSubscribe( - height: syncHeight, - count: getCountToScanPerRequest(syncHeight), - historicalMode: false, - ); - - var _scanningStream = await scanningClient.subscribe(req); - - log( - "initial request: height: $syncHeight, count: ${getCountToScanPerRequest(syncHeight)}", - LogLevel.info, - ); - - void endScanningSuccesfully() { - if (scanData.isSingleScan) - scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); - else - scanData.sendPort.send( - SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)), - ); - - _scanningStream?.close(); - _scanningStream = null; + var _scanningStream = await scanningClient.subscribe(req); log( - "ended: syncHeight: $syncHeight, chainTip: ${scanData.chainTip}, isSingleScan: ${scanData.isSingleScan}", + "initial request: height: $syncHeight, count: ${getCountToScanPerRequest(syncHeight)}", LogLevel.info, ); - } - void listenFn(Map event, ElectrumTweaksSubscribe req) async { - final response = req.onResponse(event); + void endScanningSuccesfully() { + if (isSingleScan) { + scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); + } else { + scanData.sendPort.send( + SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)), + ); + } + + _scanningStream?.close(); + _scanningStream = null; - if (response == null || _scanningStream == null) { log( - "ending: response = $response, stream = $_scanningStream", - LogLevel.error, + "ended: syncHeight: $syncHeight, chainTip: ${scanData.chainTip}, isSingleScan: ${isSingleScan}", + LogLevel.info, ); - return; } - // is success or error msg - final noData = response.message != null; + void listenFn(Map event, ElectrumTweaksSubscribe req) async { + final response = req.onResponse(event); - if (noData) { - if (scanData.isSingleScan) { - log("ending: noData and isSingleScan", LogLevel.info); - - endScanningSuccesfully(); + if (response == null || _scanningStream == null) { + log( + "ending: response = $response, stream = $_scanningStream", + LogLevel.error, + ); return; } - // re-subscribe to continue receiving messages, starting from the next unscanned height - final nextHeight = syncHeight + 1; + // is success or error msg + final noData = response.message != null; - if (nextHeight <= scanData.chainTip) { - log( - "resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", - LogLevel.info, - ); + if (noData) { + if (isSingleScan) { + log("ending: noData and isSingleScan", LogLevel.info); - final nextStream = scanningClient.subscribe( - ElectrumTweaksSubscribe( - height: nextHeight, - count: getCountToScanPerRequest(nextHeight), - historicalMode: false, - ), - ); - - if (nextStream != null) { - nextStream.listen((event) => listenFn(event, req)); - } else { - scanData.sendPort.send( - SyncResponse(scanData.height, LostConnectionSyncStatus()), - ); + endScanningSuccesfully(); + return; } - } - log( - "ending: resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", - LogLevel.info, - ); - return; - } - - final tweakHeight = response.block; - - if (initialSyncHeight < tweakHeight) initialSyncHeight = tweakHeight; - - // Continuous status UI update, send how many blocks left to scan - final syncingStatus = scanData.isSingleScan - ? SyncingSyncStatus(1, 0) - : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight); - - scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); - - try { - final blockTweaks = response.blockTweaks; - - var blockDate = DateTime.now(); - bool isDateNow = true; - - for (final txid in blockTweaks.keys) { - final tweakData = blockTweaks[txid]; - final outputPubkeys = tweakData!.outputPubkeys; - final tweak = tweakData.tweak; - - try { - final addToWallet = {}; - - // receivers.forEach((receiver) { - // NOTE: scanOutputs, from sp_scanner package, called from rust here - final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver); - - if (scanResult.isEmpty) { - continue; - } - - if (addToWallet[receiver.BSpend] == null) { - addToWallet[receiver.BSpend] = scanResult; - } else { - addToWallet[receiver.BSpend].addAll(scanResult); - } - // }); - - if (addToWallet.isEmpty) { - // no results tx, continue to next tx - continue; - } + // re-subscribe to continue receiving messages, starting from the next unscanned height + final nextHeight = syncHeight + 1; + if (nextHeight <= scanData.chainTip) { log( - "FOUND: addToWallet: ${addToWallet.length}, txid: $txid, tweak: $tweak, height: $tweakHeight", + "resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", LogLevel.info, ); - // Every tx in the block has the same date (the block date) - // So, if blockDate exists, reuse - if (isDateNow) { - try { - final tweakBlockHash = await ProxyWrapper() - .get( - clearnetUri: Uri.parse( - "https://mempool.cakewallet.com/api/v1/block-height/$tweakHeight", - ), - ) - .timeout(Duration(seconds: 15)); - final blockResponse = await ProxyWrapper() - .get( - clearnetUri: Uri.parse( - "https://mempool.cakewallet.com/api/v1/block/${tweakBlockHash.body}", - ), - ) - .timeout(Duration(seconds: 15)); - - if (blockResponse.statusCode == 200 && - blockResponse.body.isNotEmpty && - jsonDecode(blockResponse.body)['timestamp'] != null) { - blockDate = DateTime.fromMillisecondsSinceEpoch( - int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()) * 1000, - ); - isDateNow = false; - } - } catch (e, stacktrace) { - printV(stacktrace); - printV(e.toString()); - } - } - - // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop - final txInfo = ElectrumTransactionInfo( - WalletType.bitcoin, - id: txid, - height: tweakHeight, - amount: 0, - fee: 0, - direction: TransactionDirection.incoming, - isReplaced: false, - // TODO: fetch block data and get the date from it - date: scanData.network == BitcoinNetwork.mainnet - ? (isDateNow ? getDateByBitcoinHeight(tweakHeight) : blockDate) - : DateTime.now(), - confirmations: scanData.chainTip - tweakHeight + 1, - isReceivedSilentPayment: true, - isPending: false, - unspents: [], + final nextStream = scanningClient.subscribe( + ElectrumTweaksSubscribe( + height: nextHeight, + count: getCountToScanPerRequest(nextHeight), + historicalMode: hasForcedRescanHeights, + ), ); - List unspents = []; + if (nextStream != null) { + nextStream.listen((event) => listenFn(event, req)); + } else { + if (shouldUpdateSyncStatus) + scanData.sendPort.send( + SyncResponse(scanData.height, LostConnectionSyncStatus()), + ); + } + } - addToWallet.forEach((BSpend, scanResultPerLabel) { - scanResultPerLabel.forEach((label, scanOutput) { - final labelValue = label == "None" ? null : label.toString(); + log( + "ending: resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", + LogLevel.info, + ); + return; + } - (scanOutput as Map).forEach((outputPubkey, tweak) { - final t_k = tweak as String; + final tweakHeight = response.block; - final receivingOutputAddress = ECPublic.fromHex(outputPubkey) - .toTaprootAddress(tweak: false) - .toAddress(scanData.network); + // Continuous status UI update, send how many blocks left to scan + final syncingStatus = isSingleScan + ? SyncingSyncStatus(1, 0) + : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight); - final matchingOutput = outputPubkeys[outputPubkey]!; - final amount = matchingOutput.amount; - final pos = matchingOutput.vout; + if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); - // final matchingSPWallet = scanData.silentPaymentsWallets.firstWhere( - // (receiver) => receiver.B_spend.toHex() == BSpend.toString(), - // ); + try { + final blockTweaks = response.blockTweaks; - // final labelIndex = labelValue != null ? scanData.labels[label] : 0; - // final balance = ElectrumBalance(); - // balance.confirmed = amount; + var blockDate = DateTime.now(); + bool isDateNow = true; - final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( - receivingOutputAddress, - index: 0, - isHidden: false, - isUsed: true, - network: scanData.network, - silentPaymentTweak: t_k, - type: SegwitAddresType.p2tr, - txCount: 1, - balance: amount, - ); + for (final txid in blockTweaks.keys) { + final tweakData = blockTweaks[txid]; + final outputPubkeys = tweakData!.outputPubkeys; + final tweak = tweakData.tweak; - final unspent = BitcoinSilentPaymentsUnspent( - receivedAddressRecord, - txid, - amount, - pos, - silentPaymentTweak: t_k, - silentPaymentLabel: labelValue, - ); + try { + final addToWallet = {}; - unspents.add(unspent); - txInfo.unspents!.add(unspent); - txInfo.amount += unspent.value; + // receivers.forEach((receiver) { + // NOTE: scanOutputs, from sp_scanner package, called from rust here + final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver); + + if (scanResult.isEmpty) { + continue; + } + + if (addToWallet[receiver.BSpend] == null) { + addToWallet[receiver.BSpend] = scanResult; + } else { + addToWallet[receiver.BSpend].addAll(scanResult); + } + // }); + + if (addToWallet.isEmpty) { + // no results tx, continue to next tx + continue; + } + + log( + "FOUND: addToWallet: ${addToWallet.length}, txid: $txid, tweak: $tweak, height: $tweakHeight", + LogLevel.info, + ); + + // Every tx in the block has the same date (the block date) + // So, if blockDate exists, reuse + if (isDateNow) { + try { + final tweakBlockHash = await ProxyWrapper() + .get( + clearnetUri: Uri.parse( + "https://mempool.cakewallet.com/api/v1/block-height/$tweakHeight", + ), + ) + .timeout(Duration(seconds: 15)); + final blockResponse = await ProxyWrapper() + .get( + clearnetUri: Uri.parse( + "https://mempool.cakewallet.com/api/v1/block/${tweakBlockHash.body}", + ), + ) + .timeout(Duration(seconds: 15)); + + if (blockResponse.statusCode == 200 && + blockResponse.body.isNotEmpty && + jsonDecode(blockResponse.body)['timestamp'] != null) { + blockDate = DateTime.fromMillisecondsSinceEpoch( + int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()) * 1000, + ); + isDateNow = false; + } + } catch (e, stacktrace) { + printV(stacktrace); + printV(e.toString()); + } + } + + // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop + final txInfo = ElectrumTransactionInfo( + WalletType.bitcoin, + id: txid, + height: tweakHeight, + amount: 0, + fee: 0, + direction: TransactionDirection.incoming, + isReplaced: false, + // TODO: fetch block data and get the date from it + date: scanData.network == BitcoinNetwork.mainnet + ? (isDateNow ? getDateByBitcoinHeight(tweakHeight) : blockDate) + : DateTime.now(), + confirmations: scanData.chainTip - tweakHeight + 1, + isReceivedSilentPayment: true, + isPending: false, + unspents: [], + ); + + List unspents = []; + + addToWallet.forEach((BSpend, scanResultPerLabel) { + scanResultPerLabel.forEach((label, scanOutput) { + final labelValue = label == "None" ? null : label.toString(); + + (scanOutput as Map).forEach((outputPubkey, tweak) { + final t_k = tweak as String; + + final receivingOutputAddress = ECPublic.fromHex(outputPubkey) + .toTaprootAddress(tweak: false) + .toAddress(scanData.network); + + final matchingOutput = outputPubkeys[outputPubkey]!; + final amount = matchingOutput.amount; + final pos = matchingOutput.vout; + final spent = matchingOutput.spendingInput; + + // final matchingSPWallet = scanData.silentPaymentsWallets.firstWhere( + // (receiver) => receiver.B_spend.toHex() == BSpend.toString(), + // ); + + // final labelIndex = labelValue != null ? scanData.labels[label] : 0; + // final balance = ElectrumBalance(); + // balance.confirmed = amount; + + final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( + receivingOutputAddress, + index: 0, + isHidden: false, + isUsed: true, + network: scanData.network, + silentPaymentTweak: t_k, + type: SegwitAddresType.p2tr, + txCount: 1, + balance: amount, + ); + + final unspent = BitcoinSilentPaymentsUnspent( + receivedAddressRecord, + txid, + amount, + pos, + silentPaymentTweak: t_k, + silentPaymentLabel: labelValue, + ); + + if (spent == null) { + unspents.add(unspent); + txInfo.unspents!.add(unspent); + } + + txInfo.amount += unspent.value; + }); }); }); - }); - scanData.sendPort.send({txInfo.id: txInfo}); - } catch (e, stacktrace) { + scanData.sendPort.send({txInfo.id: txInfo}); + } catch (e, stacktrace) { + if (shouldUpdateSyncStatus) + scanData.sendPort.send( + SyncResponse(syncHeight, LostConnectionSyncStatus()), + ); + + log(stacktrace.toString(), LogLevel.error); + log(e.toString(), LogLevel.error); + return; + } + } + } catch (e, stacktrace) { + if (shouldUpdateSyncStatus) scanData.sendPort.send( SyncResponse(syncHeight, LostConnectionSyncStatus()), ); - log(stacktrace.toString(), LogLevel.error); - log(e.toString(), LogLevel.error); - return; - } + log(stacktrace.toString(), LogLevel.error); + log(e.toString(), LogLevel.error); + return; } - } catch (e, stacktrace) { - scanData.sendPort.send( - SyncResponse(syncHeight, LostConnectionSyncStatus()), - ); - log(stacktrace.toString(), LogLevel.error); - log(e.toString(), LogLevel.error); - return; + syncHeight = tweakHeight; + + if ((tweakHeight >= scanData.chainTip) || isSingleScan) { + endScanningSuccesfully(); + } } - syncHeight = tweakHeight; - - if ((tweakHeight >= scanData.chainTip) || scanData.isSingleScan) { - endScanningSuccesfully(); - } + _scanningStream?.listen((event) => listenFn(event, req)); } - _scanningStream?.listen((event) => listenFn(event, req)); + if (scanData.rescanHeights != null) { + for (final height in scanData.rescanHeights!) { + log("rescanning from height: $height", LogLevel.info); + scan(height, true); + } + } else { + scan(scanData.height, scanData.isSingleScan); + } } catch (e) { log("Error in _handleScanSilentPayments: $e", LogLevel.error); - scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus())); + if (shouldUpdateSyncStatus) + scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus())); } } diff --git a/cw_core/lib/sync_status.dart b/cw_core/lib/sync_status.dart index e9b44984d..0126ea7f6 100644 --- a/cw_core/lib/sync_status.dart +++ b/cw_core/lib/sync_status.dart @@ -99,29 +99,29 @@ class SyncingSyncStatus extends SyncStatus { return newDuration; } - final currentSeconds = lastEtaDuration!.inSeconds; - final newSeconds = newDuration.inSeconds; - final diff = (newSeconds - currentSeconds).abs(); + final currentMs = lastEtaDuration!.inMilliseconds; + final newMs = newDuration.inMilliseconds; + final diff = ((newMs - currentMs) / 1000).abs(); // Apply different smoothing based on the magnitude of change if (diff > 3600) { // If it's more than 1 hour difference, it's a large change so we move by max 30 minutes - final direction = newSeconds > currentSeconds ? 1 : -1; - final maxChange = 30 * 60; - final adjustedSeconds = currentSeconds + (direction * maxChange); - return Duration(seconds: adjustedSeconds); + final direction = newMs > currentMs ? 1 : -1; + final maxChange = 30 * 60 * 1000; + final adjustedMs = currentMs + (direction * maxChange); + return Duration(milliseconds: adjustedMs); } else if (diff > 300) { // If it's more than 5 minutes difference, it's a medium change so we move by max 2 minutes - final direction = newSeconds > currentSeconds ? 1 : -1; - final maxChange = 2 * 60; - final adjustedSeconds = currentSeconds + (direction * maxChange); - return Duration(seconds: adjustedSeconds); + final direction = newMs > currentMs ? 1 : -1; + final maxChange = 2 * 60 * 1000; + final adjustedMs = currentMs + (direction * maxChange); + return Duration(milliseconds: adjustedMs); } else if (diff > 60) { - // If it's more than 1 minute difference, it's a small change so we move by max 30 seconds - final direction = newSeconds > currentSeconds ? 1 : -1; - final maxChange = 30; - final adjustedSeconds = currentSeconds + (direction * maxChange); - return Duration(seconds: adjustedSeconds); + // If it's more than 1 minute difference, it's a small change so we move by max 30 ms + final direction = newMs > currentMs ? 1 : -1; + final maxChange = 30 * 1000; + final adjustedMs = currentMs + (direction * maxChange); + return Duration(milliseconds: adjustedMs); } return newDuration; @@ -146,8 +146,8 @@ class SyncingSyncStatus extends SyncStatus { return DateTime.now().add(const Duration(days: 2)); } int remainingBlocks = this.blocksLeft; - double timeRemainingSeconds = remainingBlocks / rate; - return DateTime.now().add(Duration(seconds: timeRemainingSeconds.round())); + double timeRemainingMs = remainingBlocks / rate; + return DateTime.now().add(Duration(milliseconds: timeRemainingMs.round())); } // Enhanced block rate calculation with weighted averages @@ -176,12 +176,12 @@ class SyncingSyncStatus extends SyncStatus { final timeDifference = next.key.difference(current.key); - if (timeDifference.inSeconds <= 0) continue; // Skip invalid time + if (timeDifference.inMilliseconds <= 0) continue; // Skip invalid time // Weight recent data more heavily (exponential decay) final weight = 1.0 / (1.0 + (sortedData.length - 1 - i) * 0.1); - totalWeightedTime += timeDifference.inSeconds * weight; + totalWeightedTime += timeDifference.inMilliseconds * weight; totalWeightedBlocks += blocksProcessed * weight; totalWeight += weight; }