diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d3b981..b4dbbe8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "org.vextaproject.wallet" minSdk = 26 targetSdk = 36 - versionCode = 37 - versionName = "0.6.7" + versionCode = 38 + versionName = "0.6.8" } signingConfigs { diff --git a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt index 33f4990..8759fe4 100644 --- a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt +++ b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt @@ -37,6 +37,7 @@ object BlockScanner { val height: Int, val netSatoshis: Long, val blockTime: Long, + val isCoinbase: Boolean = false, val isPending: Boolean = false, val feeSatoshis: Long? = null, val recipientAddress: String? = null @@ -263,7 +264,8 @@ object BlockScanner { .toHex(), height = height, netSatoshis = netSatoshis, - blockTime = blockTime + blockTime = blockTime, + isCoinbase = transaction.isCoinbase ) ) } diff --git a/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt index 63982fe..1b4bee7 100644 --- a/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt +++ b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt @@ -136,6 +136,7 @@ object HeaderSync { var versionInfo: VersionInfo? = null var gotVerack = false var requested = false + var totalReceived = 0 val cachedBefore = chain.last().height repeat(200) { @@ -153,6 +154,8 @@ object HeaderSync { "headers" -> { val received = parseHeaders(message.second, chain) + totalReceived += received + val remote = versionInfo ?: throw IllegalStateException("missing node version") @@ -162,8 +165,25 @@ object HeaderSync { ) } + if (chain.last().height < remote.height) { + if (received == 0) { + throw IllegalStateException( + "peer advertises height ${remote.height} " + + "but returned no additional headers" + ) + } + + sendMessage( + output, + "getheaders", + createGetHeadersPayload(chain) + ) + + return@repeat + } + return SyncResult( - received = received, + received = totalReceived, cachedBefore = cachedBefore, chainHeight = chain.last().height, lastHash = chain.last().hashDisplay @@ -175,7 +195,7 @@ object HeaderSync { sendMessage( output, "getheaders", - createGetHeadersPayload(chain.last().hashWire) + createGetHeadersPayload(chain) ) requested = true } @@ -201,6 +221,8 @@ object HeaderSync { ) } + val receivedHeaders = mutableListOf() + repeat(count) { if (buffer.remaining() < 81) { throw IllegalStateException("truncated headers message") @@ -217,7 +239,33 @@ object HeaderSync { ) } - appendVerifiedHeader(chain, raw) + receivedHeaders.add(raw) + } + + if (receivedHeaders.isNotEmpty()) { + val firstPreviousHash = + receivedHeaders.first().copyOfRange(4, 36) + + if (!firstPreviousHash.contentEquals(chain.last().hashWire)) { + val ancestorIndex = + chain.indexOfLast { + it.hashWire.contentEquals(firstPreviousHash) + } + + if (ancestorIndex < 0) { + throw IllegalStateException( + "peer returned headers with no common ancestor" + ) + } + + while (chain.lastIndex > ancestorIndex) { + chain.removeAt(chain.lastIndex) + } + } + + receivedHeaders.forEach { raw -> + appendVerifiedHeader(chain, raw) + } } return count @@ -375,12 +423,38 @@ object HeaderSync { return compact } - fun createGetHeadersPayload(locatorHash: ByteArray): ByteArray { + fun createGetHeadersPayload(chain: List): ByteArray { val out = ByteArrayOutputStream() + val locator = mutableListOf() + + var index = chain.lastIndex + var step = 1 + + while (index >= 0) { + locator.add(chain[index].hashWire) + + if (index == 0) { + break + } + + index = (index - step).coerceAtLeast(0) + + if (locator.size >= 10) { + step *= 2 + } + } + + if (!locator.last().contentEquals(chain.first().hashWire)) { + locator.add(chain.first().hashWire) + } writeInt32LE(out, PROTOCOL_VERSION) - writeCompactSize(out, 1) - out.write(locatorHash) + writeCompactSize(out, locator.size) + + locator.forEach { hash -> + out.write(hash) + } + out.write(ByteArray(32)) return out.toByteArray() diff --git a/app/src/main/java/org/vextaproject/wallet/MainActivity.kt b/app/src/main/java/org/vextaproject/wallet/MainActivity.kt index d763973..507a54a 100644 --- a/app/src/main/java/org/vextaproject/wallet/MainActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/MainActivity.kt @@ -119,6 +119,7 @@ class MainActivity : Activity() { ) val results = mutableListOf() + var successfulPeerSyncs = 0 for ((index, peer) in peers.withIndex()) { try { @@ -128,6 +129,8 @@ class MainActivity : Activity() { saveChain(chain) } + successfulPeerSyncs++ + results.add( "$peer:$PORT\n" + "Handshake: successful\n" + @@ -148,7 +151,10 @@ class MainActivity : Activity() { runOnUiThread { if (intent.getBooleanExtra("sync_only", false)) { - if (chain.last().height > 0) { + if ( + successfulPeerSyncs > 0 && + chain.last().height > 0 + ) { setResult( RESULT_OK, android.content.Intent().putExtra( @@ -271,6 +277,7 @@ class MainActivity : Activity() { var versionInfo: VersionInfo? = null var gotVerack = false var requested = false + var totalReceived = 0 val cachedBefore = chain.last().height repeat(200) { @@ -288,6 +295,8 @@ class MainActivity : Activity() { "headers" -> { val received = parseHeaders(message.second, chain) + totalReceived += received + val remote = versionInfo ?: throw IllegalStateException("missing node version") @@ -297,8 +306,25 @@ class MainActivity : Activity() { ) } + if (chain.last().height < remote.height) { + if (received == 0) { + throw IllegalStateException( + "peer advertises height ${remote.height} " + + "but returned no additional headers" + ) + } + + sendMessage( + output, + "getheaders", + createGetHeadersPayload(chain) + ) + + return@repeat + } + return SyncResult( - received = received, + received = totalReceived, cachedBefore = cachedBefore, chainHeight = chain.last().height, lastHash = chain.last().hashDisplay @@ -310,7 +336,7 @@ class MainActivity : Activity() { sendMessage( output, "getheaders", - createGetHeadersPayload(chain.last().hashWire) + createGetHeadersPayload(chain) ) requested = true } @@ -336,6 +362,8 @@ class MainActivity : Activity() { ) } + val receivedHeaders = mutableListOf() + repeat(count) { if (buffer.remaining() < 81) { throw IllegalStateException("truncated headers message") @@ -352,7 +380,33 @@ class MainActivity : Activity() { ) } - appendVerifiedHeader(chain, raw) + receivedHeaders.add(raw) + } + + if (receivedHeaders.isNotEmpty()) { + val firstPreviousHash = + receivedHeaders.first().copyOfRange(4, 36) + + if (!firstPreviousHash.contentEquals(chain.last().hashWire)) { + val ancestorIndex = + chain.indexOfLast { + it.hashWire.contentEquals(firstPreviousHash) + } + + if (ancestorIndex < 0) { + throw IllegalStateException( + "peer returned headers with no common ancestor" + ) + } + + while (chain.lastIndex > ancestorIndex) { + chain.removeAt(chain.lastIndex) + } + } + + receivedHeaders.forEach { raw -> + appendVerifiedHeader(chain, raw) + } } return count @@ -510,12 +564,38 @@ class MainActivity : Activity() { return compact } - private fun createGetHeadersPayload(locatorHash: ByteArray): ByteArray { + private fun createGetHeadersPayload(chain: List): ByteArray { val out = ByteArrayOutputStream() + val locator = mutableListOf() + + var index = chain.lastIndex + var step = 1 + + while (index >= 0) { + locator.add(chain[index].hashWire) + + if (index == 0) { + break + } + + index = (index - step).coerceAtLeast(0) + + if (locator.size >= 10) { + step *= 2 + } + } + + if (!locator.last().contentEquals(chain.first().hashWire)) { + locator.add(chain.first().hashWire) + } writeInt32LE(out, PROTOCOL_VERSION) - writeCompactSize(out, 1) - out.write(locatorHash) + writeCompactSize(out, locator.size) + + locator.forEach { hash -> + out.write(hash) + } + out.write(ByteArray(32)) return out.toByteArray() diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index fc51a0b..1735cb2 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -110,6 +110,11 @@ class WalletActivity : FragmentActivity() { "background_transaction_history" private const val PREF_LAST_BACKGROUND_SCAN_HEIGHT = "last_background_scan_height" + private const val PREF_LAST_HEADER_HEIGHT = + "last_header_height" + private const val PREF_BACKGROUND_TX_HISTORY_VERSION = + "background_transaction_history_version" + private const val BACKGROUND_TX_HISTORY_VERSION = 4 private const val PREF_FIAT_CURRENCY = "preferred_fiat_currency" @@ -147,6 +152,7 @@ class WalletActivity : FragmentActivity() { private var latestSpendableUtxos = emptyList() private var latestChainHeight = 0 + private var walletFiatRefresh: ((Long) -> Unit)? = null private fun spendableUtxos( utxos: List, @@ -193,7 +199,7 @@ class WalletActivity : FragmentActivity() { latestChainHeight = getSharedPreferences(PREFS, MODE_PRIVATE) .getInt( - PREF_LAST_BACKGROUND_SCAN_HEIGHT, + PREF_LAST_HEADER_HEIGHT, 0 ) .coerceAtLeast(0) @@ -217,6 +223,10 @@ class WalletActivity : FragmentActivity() { 100_000_000.0 ) + walletFiatRefresh?.invoke( + balanceSatoshis + ) + walletHistoryView?.text = formatTransactionHistory( cachedTransactions, @@ -429,7 +439,7 @@ class WalletActivity : FragmentActivity() { mainWalletVisible && !blockchainScanRunning ) { - startHeaderSyncAndScan() + startBackgroundHeaderSyncAndScan() } }, 400L) } @@ -870,12 +880,21 @@ class WalletActivity : FragmentActivity() { } blockchainScanRunning = true + blockchainScanStatus?.text = + "Synchronizing block headers..." Thread { try { val chain = HeaderSync.loadCachedChain(this) + runOnUiThread { + blockchainScanStatus?.text = + "Synchronizing block headers...\n" + + "Cached height: ${chain.last().height}" + } + val syncErrors = mutableListOf() + var successfulPeerSyncs = 0 for (peer in listOf("87.106.99.23", "74.208.53.160")) { try { @@ -884,6 +903,14 @@ class WalletActivity : FragmentActivity() { if (result.received > 0) { HeaderSync.saveChain(this, chain) } + + successfulPeerSyncs++ + + runOnUiThread { + blockchainScanStatus?.text = + "Header sync OK: ${result.chainHeight}\n" + + "Peer: $peer" + } } catch (e: Exception) { syncErrors.add( "$peer: ${e.message ?: e.javaClass.simpleName}" @@ -891,7 +918,10 @@ class WalletActivity : FragmentActivity() { } } - if (chain.last().height <= 0) { + if ( + successfulPeerSyncs == 0 || + chain.last().height <= 0 + ) { runOnUiThread { blockchainScanRunning = false blockchainScanStatus?.text = @@ -904,9 +934,12 @@ class WalletActivity : FragmentActivity() { runOnUiThread { runBlockchainScan() } - } catch (_: Exception) { + } catch (e: Exception) { runOnUiThread { blockchainScanRunning = false + blockchainScanStatus?.text = + "Background synchronization failed:\n" + + (e.message ?: e.javaClass.simpleName) } } }.start() @@ -947,6 +980,14 @@ class WalletActivity : FragmentActivity() { -1 ) ?: -1 + if (height >= 0) { + latestChainHeight = height + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putInt(PREF_LAST_HEADER_HEIGHT, height) + .apply() + } + blockchainScanStatus?.text = if (height >= 0) { "Headers synchronized to block $height.\n" + "Scanning compact filters..." @@ -1073,7 +1114,7 @@ class WalletActivity : FragmentActivity() { try { val parts = value.split("|") - if (parts.size != 4) { + if (parts.size != 5) { return@mapNotNull null } @@ -1090,6 +1131,7 @@ class WalletActivity : FragmentActivity() { parts[2].toLong() }, blockTime = parts[3].toLong(), + isCoinbase = parts[4].toBooleanStrict(), feeSatoshis = outgoing?.feeSatoshis, recipientAddress = outgoing?.recipientAddress @@ -1135,11 +1177,23 @@ class WalletActivity : FragmentActivity() { } private fun hasCachedWalletState(): Boolean { - return getSharedPreferences(PREFS, MODE_PRIVATE) - .getInt( + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + + val scanHeight = + preferences.getInt( PREF_LAST_BACKGROUND_SCAN_HEIGHT, 0 - ) > 0 + ) + + val cacheVersion = + preferences.getInt( + PREF_BACKGROUND_TX_HISTORY_VERSION, + 0 + ) + + return scanHeight > 0 && + cacheVersion == BACKGROUND_TX_HISTORY_VERSION } private fun preferredFiatCurrency(): String { @@ -1395,6 +1449,9 @@ class WalletActivity : FragmentActivity() { } }, blockTime = newest.blockTime, + isCoinbase = transactions.any { + it.isCoinbase + }, feeSatoshis = outgoing?.feeSatoshis, recipientAddress = @@ -1586,8 +1643,22 @@ class WalletActivity : FragmentActivity() { 1 ).coerceAtLeast(1) - "Block ${transaction.height}\n" + - "Confirmations: $confirmations" + if (transaction.isCoinbase) { + val maturityStatus = + if (confirmations >= COINBASE_MATURITY) { + "Coinbase reward — Mature" + } else { + "Coinbase reward — Immature" + } + + "Block ${transaction.height}\n" + + "$maturityStatus\n" + + "Confirmations: $confirmations / " + + "$COINBASE_MATURITY" + } else { + "Block ${transaction.height}\n" + + "Confirmations: $confirmations" + } } "$direction $sign$amount VTX\n" + @@ -1637,7 +1708,14 @@ class WalletActivity : FragmentActivity() { val details = StringBuilder() - details.append("Type: $direction\n") + details.append( + "Type: " + + if (transaction.isCoinbase) { + "Coinbase reward\n" + } else { + "$direction\n" + } + ) details.append("Amount: $amount VTX\n") details.append("Date & time: $dateTime\n") @@ -1645,9 +1723,30 @@ class WalletActivity : FragmentActivity() { details.append("Status: Pending\n") details.append("Confirmations: 0\n") } else { - details.append("Status: Confirmed\n") - details.append("Block: ${transaction.height}\n") - details.append("Confirmations: $confirmations\n") + if (transaction.isCoinbase) { + val mature = + confirmations >= COINBASE_MATURITY + + details.append( + "Status: " + + if (mature) { + "Mature\n" + } else { + "Immature\n" + } + ) + details.append("Block: ${transaction.height}\n") + details.append( + "Confirmations: $confirmations / " + + "$COINBASE_MATURITY\n" + ) + } else { + details.append("Status: Confirmed\n") + details.append("Block: ${transaction.height}\n") + details.append( + "Confirmations: $confirmations\n" + ) + } } transaction.feeSatoshis?.let { fee -> @@ -2144,7 +2243,7 @@ class WalletActivity : FragmentActivity() { latestChainHeight = getSharedPreferences(PREFS, MODE_PRIVATE) .getInt( - PREF_LAST_BACKGROUND_SCAN_HEIGHT, + PREF_LAST_HEADER_HEIGHT, 0 ) .coerceAtLeast(0) @@ -2337,30 +2436,37 @@ class WalletActivity : FragmentActivity() { addView(alternativesContainer) - loadFiatValues( - cachedBalance - ) { values -> - fiatValues = values + walletFiatRefresh = { + balanceSatoshis -> + loadFiatValues( + balanceSatoshis + ) { values -> + fiatValues = values - preferredFiatView.text = - preferredFiatText( - alternativesContainer.visibility == - View.VISIBLE - ) + preferredFiatView.text = + preferredFiatText( + alternativesContainer.visibility == + View.VISIBLE + ) - alternativeViews.forEach { - (currency, view) -> - values[currency]?.let { - value -> - view.text = - formatFiatValue( - currency, - value - ) + alternativeViews.forEach { + (currency, view) -> + values[currency]?.let { + value -> + view.text = + formatFiatValue( + currency, + value + ) + } } } } + walletFiatRefresh?.invoke( + cachedBalance + ) + addView(space(8)) addView( TextView(this@WalletActivity).apply { @@ -2422,7 +2528,7 @@ class WalletActivity : FragmentActivity() { symbol = "↻", label = "Scan" ) { - startHeaderSyncAndScan() + startBackgroundHeaderSyncAndScan() } ) @@ -2527,7 +2633,7 @@ class WalletActivity : FragmentActivity() { mainWalletVisible && !blockchainScanRunning ) { - startHeaderSyncAndScan() + startBackgroundHeaderSyncAndScan() } }, 500L) } diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt index e35da5e..7f66ce7 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt @@ -43,13 +43,15 @@ class WalletMonitoringService : Service() { "tx_notifications_initialized" private const val PREF_LAST_BACKGROUND_SCAN_HEIGHT = "last_background_scan_height" + private const val PREF_LAST_HEADER_HEIGHT = + "last_header_height" private const val PREF_BACKGROUND_UTXO_PREFIX = "background_utxos_" private const val PREF_BACKGROUND_TX_HISTORY = "background_transaction_history" private const val PREF_BACKGROUND_TX_HISTORY_VERSION = "background_transaction_history_version" - private const val BACKGROUND_TX_HISTORY_VERSION = 2 + private const val BACKGROUND_TX_HISTORY_VERSION = 4 } private val handler = Handler(Looper.getMainLooper()) @@ -97,10 +99,24 @@ class WalletMonitoringService : Service() { try { val chain = HeaderSync.loadCachedChain(this) + val cachedTipHeightBeforeSync = chain.last().height + val cachedTipHashBeforeSync = chain.last().hashDisplay + var reorgDetected = false + for (peer in listOf("87.106.99.23", "74.208.53.160")) { try { val result = HeaderSync.syncFromPeer(peer, chain) + if (cachedTipHeightBeforeSync > 0) { + val originalTipStillCanonical = + chain.getOrNull(cachedTipHeightBeforeSync) + ?.hashDisplay == cachedTipHashBeforeSync + + if (!originalTipStillCanonical) { + reorgDetected = true + } + } + if (result.received > 0) { HeaderSync.saveChain(this, chain) } @@ -122,6 +138,13 @@ class WalletMonitoringService : Service() { val localHeight = chain.last().height + preferences.edit() + .putInt( + PREF_LAST_HEADER_HEIGHT, + localHeight + ) + .apply() + val previousHeight = preferences .getInt( @@ -145,7 +168,8 @@ class WalletMonitoringService : Service() { ) == BACKGROUND_TX_HISTORY_VERSION val fullScanRequired = - previousHeight <= 0 || + reorgDetected || + previousHeight <= 0 || previousHeight > localHeight || !backgroundCacheAvailable || !transactionHistoryCurrent @@ -226,7 +250,10 @@ class WalletMonitoringService : Service() { netSatoshis = transactions.sumOf { it.netSatoshis }, - blockTime = newest.blockTime + blockTime = newest.blockTime, + isCoinbase = transactions.any { + it.isCoinbase + } ) } .filter { it.netSatoshis != 0L } @@ -308,7 +335,8 @@ class WalletMonitoringService : Service() { transaction.txid, transaction.height.toString(), transaction.netSatoshis.toString(), - transaction.blockTime.toString() + transaction.blockTime.toString(), + transaction.isCoinbase.toString() ).joinToString("|") }.toSet() @@ -325,7 +353,7 @@ class WalletMonitoringService : Service() { try { val parts = value.split("|") - if (parts.size != 4) { + if (parts.size != 5) { return@mapNotNull null } @@ -333,7 +361,8 @@ class WalletMonitoringService : Service() { txid = parts[0], height = parts[1].toInt(), netSatoshis = parts[2].toLong(), - blockTime = parts[3].toLong() + blockTime = parts[3].toLong(), + isCoinbase = parts[4].toBooleanStrict() ) } catch (_: Exception) { null