From 2cb6e40f23f130c054c18450fae0b811229f7339 Mon Sep 17 00:00:00 2001 From: abdrasulov Date: Mon, 10 Aug 2026 16:47:50 +0600 Subject: [PATCH] Scan full history on initial API restore A failed first restore attempt leaves block hashes in storage; using their height as the stop-height cutoff on retry skipped all older history for keys discovered via gap expansion, permanently losing their transactions. Apply the cutoff only to incremental re-scans of a restored wallet, and fail the sync instead of silently dropping transactions whose block hash cannot be resolved. --- .../apisync/blockchair/BlockchairApiSyncer.kt | 13 +++++++++++-- .../BlockchairTransactionProvider.kt | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairApiSyncer.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairApiSyncer.kt index d4a3503b8..fa490ff53 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairApiSyncer.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairApiSyncer.kt @@ -73,7 +73,16 @@ class BlockchairApiSyncer( private fun scanSingle(): Single = Single.create { emitter -> try { val allKeys = storage.getPublicKeys() - val stopHeight = storage.downloadedTransactionsBestBlockHeight() + // A height-based cutoff is only valid for incremental re-scans of a wallet + // whose initial restore has completed. During the initial restore the storage + // may already hold block hashes from an earlier failed attempt; using their + // height as a cutoff would skip all older history for keys discovered later + // (gap expansion), permanently losing transactions. + val stopHeight = if (apiSyncStateManager.restored) { + storage.downloadedTransactionsBestBlockHeight() + } else { + null + } fetchRecursive(allKeys, allKeys, stopHeight) fetchLastBlock() @@ -88,7 +97,7 @@ class BlockchairApiSyncer( private fun fetchRecursive( keys: List, allKeys: List, - stopHeight: Int + stopHeight: Int? ) { val publicKeyMap = mutableMapOf() val addresses = mutableListOf() diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairTransactionProvider.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairTransactionProvider.kt index c0befdd2e..085b19182 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairTransactionProvider.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/apisync/blockchair/BlockchairTransactionProvider.kt @@ -2,6 +2,7 @@ package io.horizontalsystems.bitcoincore.apisync.blockchair import io.horizontalsystems.bitcoincore.apisync.model.TransactionItem import io.horizontalsystems.bitcoincore.core.IApiTransactionProvider +import io.horizontalsystems.bitcoincore.managers.ApiManagerException class BlockchairTransactionProvider( val blockchairApi: BlockchairApi, @@ -9,11 +10,18 @@ class BlockchairTransactionProvider( ) : IApiTransactionProvider { private fun fillBlockHashes(items: List): List { - val hashesMap = blockHashFetcher.fetch(items.map { it.blockHeight }.distinct()) - return items.mapNotNull { item -> - hashesMap[item.blockHeight]?.let { - item.copy(blockHash = it) - } + val heights = items.map { it.blockHeight }.distinct() + val hashesMap = blockHashFetcher.fetch(heights) + + // Every item refers to a confirmed block, so every height must resolve. Failing + // the sync keeps it retryable; silently dropping items would lose transactions. + val missingHeights = heights.filter { it !in hashesMap } + if (missingHeights.isNotEmpty()) { + throw ApiManagerException.Other("Missing block hashes for heights: $missingHeights") + } + + return items.map { item -> + item.copy(blockHash = hashesMap.getValue(item.blockHeight)) } }