From 3e2a7150f93ee9d667b22036206b4bac1e3fafe1 Mon Sep 17 00:00:00 2001 From: Vexta Project Date: Mon, 31 Aug 2026 23:35:19 +0000 Subject: [PATCH] Release Vexta Wallet v0.6.7 --- app/build.gradle.kts | 4 +- .../org/vextaproject/wallet/BlockScanner.kt | 26 +- .../wallet/VextaTransactionSender.kt | 46 ++- .../org/vextaproject/wallet/WalletActivity.kt | 335 ++++++++++++++++-- .../wallet/WalletMonitoringService.kt | 86 ++++- 5 files changed, 443 insertions(+), 54 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1df0438..7d3b981 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 = 36 - versionName = "0.6.6" + versionCode = 37 + versionName = "0.6.7" } signingConfigs { diff --git a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt index f6a0c37..33f4990 100644 --- a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt +++ b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt @@ -28,7 +28,8 @@ object BlockScanner { val outputIndex: Long, val value: Long, val height: Int, - val addressIndex: Int + val addressIndex: Int, + val isCoinbase: Boolean ) data class WalletTransaction( @@ -56,13 +57,15 @@ object BlockScanner { val outputIndex: Long, val value: Long, val height: Int, - val addressIndex: Int + val addressIndex: Int, + val isCoinbase: Boolean ) private data class ParsedTransaction( val txidWire: ByteArray, val inputs: List, - val outputs: List + val outputs: List, + val isCoinbase: Boolean ) private data class OutPoint( @@ -139,7 +142,8 @@ object BlockScanner { outputIndex = existing.outputIndex, value = existing.value, height = existing.height, - addressIndex = existing.addressIndex + addressIndex = existing.addressIndex, + isCoinbase = existing.isCoinbase ) } var receivedTransactions = 0 @@ -235,7 +239,8 @@ object BlockScanner { transactionOutput.index.toLong(), value = transactionOutput.value, height = height, - addressIndex = addressIndex + addressIndex = addressIndex, + isCoinbase = transaction.isCoinbase ) receivedWalletValue += @@ -282,7 +287,8 @@ object BlockScanner { outputIndex = utxo.outputIndex, value = utxo.value, height = utxo.height, - addressIndex = utxo.addressIndex + addressIndex = utxo.addressIndex, + isCoinbase = utxo.isCoinbase ) } @@ -461,10 +467,16 @@ object BlockScanner { val lockTime = cursor.readBytes(4) stripped.write(lockTime) + val isCoinbase = + inputs.size == 1 && + inputs[0].index == 0xffffffffL && + inputs[0].txidWire.all { it == 0.toByte() } + return ParsedTransaction( txidWire = doubleSha256(stripped.toByteArray()), inputs = inputs, - outputs = outputs + outputs = outputs, + isCoinbase = isCoinbase ) } diff --git a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt index 542cb7e..108eca0 100644 --- a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt +++ b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt @@ -48,9 +48,40 @@ object VextaTransactionSender { val fee: Long ) + private const val COINBASE_MATURITY = 100 + + private fun validateCoinbaseMaturity( + utxos: List, + chainHeight: Int + ) { + require(chainHeight >= 0) { + "Invalid blockchain height" + } + + val immatureCoinbase = + utxos.firstOrNull { utxo -> + utxo.isCoinbase && + ( + chainHeight < utxo.height || + chainHeight - utxo.height + 1 < + COINBASE_MATURITY + ) + } + + require(immatureCoinbase == null) { + "Immature mining reward cannot be spent before 100 confirmations" + } + } + fun calculateMaxSpend( - spendableUtxos: List + spendableUtxos: List, + chainHeight: Int ): MaxSpend { + validateCoinbaseMaturity( + spendableUtxos, + chainHeight + ) + require(spendableUtxos.isNotEmpty()) { "Wallet has no spendable outputs" } @@ -81,8 +112,13 @@ object VextaTransactionSender { recipientAddress: String, amountSatoshis: Long, privateKeysByIndex: Map, - changePubKeyHash: ByteArray + changePubKeyHash: ByteArray, + chainHeight: Int ): CreatedTransaction { + validateCoinbaseMaturity( + spendableUtxos, + chainHeight + ) require(amountSatoshis > 0L) { "Amount must be greater than zero" } @@ -105,7 +141,11 @@ object VextaTransactionSender { .thenBy { it.outputIndex } ) - val maxSpend = calculateMaxSpend(orderedUtxos) + val maxSpend = + calculateMaxSpend( + orderedUtxos, + chainHeight + ) val sendingMaximum = amountSatoshis == maxSpend.amount diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index e6c823b..fc51a0b 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -74,6 +74,8 @@ import javax.crypto.spec.GCMParameterSpec class WalletActivity : FragmentActivity() { companion object { + const val COINBASE_MATURITY = 100 + private const val PREFS = "vexta_wallet" private const val KEY_ALIAS = "vexta_wallet_seed_key" private const val PREF_CIPHERTEXT = "seed_ciphertext" @@ -108,6 +110,8 @@ class WalletActivity : FragmentActivity() { "background_transaction_history" private const val PREF_LAST_BACKGROUND_SCAN_HEIGHT = "last_background_scan_height" + private const val PREF_FIAT_CURRENCY = + "preferred_fiat_currency" private const val CONTACT_PREFS = "vexta_wallet_contacts" @@ -144,6 +148,18 @@ class WalletActivity : FragmentActivity() { emptyList() private var latestChainHeight = 0 + private fun spendableUtxos( + utxos: List, + chainHeight: Int + ): List = + utxos.filter { utxo -> + !utxo.isCoinbase || + ( + chainHeight >= utxo.height && + chainHeight - utxo.height + 1 >= COINBASE_MATURITY + ) + } + private data class OutgoingTransactionRecord( val txid: String, val amountSatoshis: Long, @@ -174,7 +190,20 @@ class WalletActivity : FragmentActivity() { val cachedTransactions = loadCachedWalletTransactions() - latestSpendableUtxos = cachedUtxos + latestChainHeight = + getSharedPreferences(PREFS, MODE_PRIVATE) + .getInt( + PREF_LAST_BACKGROUND_SCAN_HEIGHT, + 0 + ) + .coerceAtLeast(0) + + latestSpendableUtxos = + spendableUtxos( + cachedUtxos, + latestChainHeight + ) + latestWalletTransactions = cachedTransactions @@ -1001,7 +1030,7 @@ class WalletActivity : FragmentActivity() { try { val parts = value.split("|") - if (parts.size != 5) { + if (parts.size != 6) { return@mapNotNull null } @@ -1010,7 +1039,8 @@ class WalletActivity : FragmentActivity() { outputIndex = parts[1].toLong(), value = parts[2].toLong(), height = parts[3].toInt(), - addressIndex = parts[4].toInt() + addressIndex = parts[4].toInt(), + isCoinbase = parts[5].toBooleanStrict() ) } catch (_: Exception) { null @@ -1112,6 +1142,109 @@ class WalletActivity : FragmentActivity() { ) > 0 } + private fun preferredFiatCurrency(): String { + return getSharedPreferences(PREFS, MODE_PRIVATE) + .getString( + PREF_FIAT_CURRENCY, + "GBP" + ) + ?.takeIf { + it in setOf("GBP", "EUR", "USD") + } + ?: "GBP" + } + + private fun setPreferredFiatCurrency( + currency: String + ) { + if (currency !in setOf("GBP", "EUR", "USD")) { + return + } + + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putString( + PREF_FIAT_CURRENCY, + currency + ) + .apply() + } + + private fun fiatSymbol(currency: String): String = + when (currency) { + "GBP" -> "£" + "EUR" -> "€" + "USD" -> "$" + else -> "" + } + + private fun loadFiatValues( + balanceSatoshis: Long, + onLoaded: (Map) -> Unit + ) { + Thread { + try { + val nestExJson = + org.json.JSONObject( + java.net.URL( + "https://trade.nestex.one/api/cg/tickers/VTX_USDT" + ).readText() + ) + + val vtxUsdt = + nestExJson.getString("last_price") + .toDouble() + + val fxJson = + org.json.JSONObject( + java.net.URL( + "https://api.frankfurter.app/latest?from=USD&to=GBP,EUR" + ).readText() + ) + + val rates = + fxJson.getJSONObject("rates") + + val gbpRate = + rates.getDouble("GBP") + + val eurRate = + rates.getDouble("EUR") + + val vtxBalance = + balanceSatoshis.toDouble() / + 100_000_000.0 + + val usdValue = + vtxBalance * vtxUsdt + + val values = + mapOf( + "USD" to usdValue, + "GBP" to usdValue * gbpRate, + "EUR" to usdValue * eurRate + ) + + runOnUiThread { + onLoaded(values) + } + } catch (_: Exception) { + } + }.start() + } + + private fun formatFiatValue( + currency: String, + value: Double + ): String { + return String.format( + java.util.Locale.getDefault(), + "%s%,.2f", + fiatSymbol(currency), + value + ) + } + private fun currentReceiveAddressIndex(): Int { return getSharedPreferences(PREFS, MODE_PRIVATE) .getInt(PREF_RECEIVE_ADDRESS_INDEX, 0) @@ -1349,7 +1482,12 @@ class WalletActivity : FragmentActivity() { } runOnUiThread { - latestSpendableUtxos = mergedUtxos + latestSpendableUtxos = + spendableUtxos( + mergedUtxos, + latestChainHeight + ) + latestWalletTransactions = mergedTransactions @@ -2014,7 +2152,12 @@ class WalletActivity : FragmentActivity() { val cachedTransactions = loadCachedWalletTransactions() - latestSpendableUtxos = cachedUtxos + latestSpendableUtxos = + spendableUtxos( + cachedUtxos, + latestChainHeight + ) + latestWalletTransactions = cachedTransactions val cachedBalance = @@ -2075,6 +2218,149 @@ class WalletActivity : FragmentActivity() { ) addView(space(12)) addView(balanceView) + + addView(space(10)) + + val preferredCurrency = + preferredFiatCurrency() + + val alternativeCurrencies = + listOf("GBP", "EUR", "USD") + .filter { + it != preferredCurrency + } + + var fiatValues = + emptyMap() + + val alternativesContainer = + LinearLayout( + this@WalletActivity + ).apply { + orientation = + LinearLayout.VERTICAL + gravity = Gravity.CENTER + visibility = View.GONE + } + + val alternativeViews = + mutableMapOf() + + fun preferredFiatText( + expanded: Boolean + ): String { + val value = + fiatValues[preferredCurrency] + + val formatted = + if (value != null) { + formatFiatValue( + preferredCurrency, + value + ) + } else { + "${fiatSymbol(preferredCurrency)}—" + } + + return "≈ $formatted " + + if (expanded) "▴" else "▾" + } + + val preferredFiatView = + TextView( + this@WalletActivity + ).apply { + text = preferredFiatText(false) + textSize = 16f + setTextColor(textPrimary) + gravity = Gravity.CENTER + typeface = + Typeface.DEFAULT_BOLD + isClickable = true + isFocusable = true + + setOnClickListener { + val expanding = + alternativesContainer.visibility != + View.VISIBLE + + alternativesContainer.visibility = + if (expanding) { + View.VISIBLE + } else { + View.GONE + } + + text = + preferredFiatText(expanding) + } + } + + addView(preferredFiatView) + + alternativeCurrencies.forEach { + currency -> + + val fiatView = + TextView( + this@WalletActivity + ).apply { + text = + "${fiatSymbol(currency)}—" + textSize = 15f + setTextColor(textSecondary) + gravity = Gravity.CENTER + setPadding( + 0, + dp(8), + 0, + dp(8) + ) + isClickable = true + isFocusable = true + + setOnClickListener { + setPreferredFiatCurrency( + currency + ) + showMainWallet() + } + } + + alternativeViews[currency] = + fiatView + + alternativesContainer.addView( + fiatView + ) + } + + addView(alternativesContainer) + + loadFiatValues( + cachedBalance + ) { values -> + fiatValues = values + + preferredFiatView.text = + preferredFiatText( + alternativesContainer.visibility == + View.VISIBLE + ) + + alternativeViews.forEach { + (currency, view) -> + values[currency]?.let { + value -> + view.text = + formatFiatValue( + currency, + value + ) + } + } + } + addView(space(8)) addView( TextView(this@WalletActivity).apply { @@ -2824,7 +3110,7 @@ class WalletActivity : FragmentActivity() { val nameInput = inputField( - hint = "Contact name", + hint = "Saved address name", singleLine = true ).apply { setText(existing?.name ?: "") @@ -2846,9 +3132,9 @@ class WalletActivity : FragmentActivity() { AlertDialog.Builder(this) .setTitle( if (existing == null) { - "Add contact" + "Add saved address" } else { - "Edit contact" + "Edit saved address" } ) .setView(content) @@ -2873,7 +3159,7 @@ class WalletActivity : FragmentActivity() { if (name.isBlank()) { Toast.makeText( this, - "Enter a contact name", + "Enter a name for this saved address", Toast.LENGTH_LONG ).show() @@ -2918,7 +3204,7 @@ class WalletActivity : FragmentActivity() { if (duplicate) { Toast.makeText( this, - "This address is already in Contacts", + "This address is already saved", Toast.LENGTH_LONG ).show() @@ -2959,9 +3245,9 @@ class WalletActivity : FragmentActivity() { Toast.makeText( this, if (existing == null) { - "Contact added" + "Saved address added" } else { - "Contact updated" + "Saved address updated" }, Toast.LENGTH_SHORT ).show() @@ -2982,9 +3268,9 @@ class WalletActivity : FragmentActivity() { contact: WalletContact ) { AlertDialog.Builder(this) - .setTitle("Delete contact") + .setTitle("Delete saved address") .setMessage( - "Delete ${contact.name} from Contacts?" + "Delete ${contact.name} from Saved addresses?" ) .setNegativeButton("Cancel", null) .setPositiveButton("Delete") { _, _ -> @@ -2998,7 +3284,7 @@ class WalletActivity : FragmentActivity() { Toast.makeText( this, - "Contact deleted", + "Saved address deleted", Toast.LENGTH_SHORT ).show() @@ -3071,7 +3357,7 @@ class WalletActivity : FragmentActivity() { content.addView(space(22)) content.addView( - primaryButton("Edit contact") { + primaryButton("Edit saved address") { showContactEditor( recipientInput, contact @@ -3082,7 +3368,7 @@ class WalletActivity : FragmentActivity() { content.addView(space(12)) content.addView( - primaryButton("Delete contact") { + primaryButton("Delete saved address") { deleteContact( recipientInput, contact @@ -3118,7 +3404,7 @@ class WalletActivity : FragmentActivity() { if (contacts.isEmpty()) { content.addView( smallStatus( - "No contacts saved yet." + "No saved addresses yet." ) ) } else { @@ -3149,7 +3435,7 @@ class WalletActivity : FragmentActivity() { content.addView(space(16)) content.addView( - primaryButton("Add contact") { + primaryButton("Add saved address") { contactsDialog.dismiss() showContactEditor( @@ -3165,7 +3451,7 @@ class WalletActivity : FragmentActivity() { contactsDialog = AlertDialog.Builder(this) - .setTitle("Contacts") + .setTitle("Saved addresses") .setView(scroll) .setNegativeButton("Close", null) .create() @@ -3252,7 +3538,7 @@ class WalletActivity : FragmentActivity() { addView(space(10)) addView( - secondaryButton("Contacts") { + secondaryButton("Saved addresses") { showContactsDialog( recipientInput ) @@ -3286,7 +3572,8 @@ class WalletActivity : FragmentActivity() { try { val maxSpend = VextaTransactionSender.calculateMaxSpend( - latestSpendableUtxos + latestSpendableUtxos, + latestChainHeight ) amountInput.setText( @@ -3433,7 +3720,9 @@ class WalletActivity : FragmentActivity() { privateKeysByIndex = privateKeysByIndex, changePubKeyHash = - changePubKeyHash + changePubKeyHash, + chainHeight = + latestChainHeight ) val amountDisplay = String.format( diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt index 5f447e8..e35da5e 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt @@ -49,7 +49,7 @@ class WalletMonitoringService : Service() { "background_transaction_history" private const val PREF_BACKGROUND_TX_HISTORY_VERSION = "background_transaction_history_version" - private const val BACKGROUND_TX_HISTORY_VERSION = 1 + private const val BACKGROUND_TX_HISTORY_VERSION = 2 } private val handler = Handler(Looper.getMainLooper()) @@ -210,6 +210,9 @@ class WalletMonitoringService : Service() { val mergedTransactions = allTransactions + .distinctBy { + "${it.txid}:${it.height}:${it.netSatoshis}:${it.blockTime}" + } .groupBy { it.txid } .map { (_, transactions) -> val newest = @@ -228,9 +231,15 @@ class WalletMonitoringService : Service() { } .filter { it.netSatoshis != 0L } - updateIncomingTransactionsAndNotify( - mergedTransactions - ) + if (fullScanRequired) { + synchronizeKnownIncomingTransactions( + mergedTransactions + ) + } else { + updateIncomingTransactionsAndNotify( + mergedTransactions + ) + } val cachedTransactions = if (fullScanRequired) { @@ -332,6 +341,36 @@ class WalletMonitoringService : Service() { } } + private fun synchronizeKnownIncomingTransactions( + transactions: List + ) { + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + + val knownTxids = + preferences.getStringSet( + PREF_KNOWN_INCOMING_TXIDS, + emptySet() + )?.toMutableSet() ?: mutableSetOf() + + knownTxids.addAll( + transactions + .filter { it.netSatoshis > 0L } + .map { it.txid } + ) + + preferences.edit() + .putBoolean( + PREF_TX_NOTIFICATIONS_INITIALIZED, + true + ) + .putStringSet( + PREF_KNOWN_INCOMING_TXIDS, + knownTxids + ) + .apply() + } + private fun updateIncomingTransactionsAndNotify( transactions: List ) { @@ -371,25 +410,32 @@ class WalletMonitoringService : Service() { )?.toMutableSet() ?: mutableSetOf() incoming + .distinctBy { it.txid } .filter { it.txid !in knownTxids } .sortedBy { it.height } .forEach { transaction -> - showIncomingPaymentNotification( - transaction.netSatoshis - ) knownTxids.add(transaction.txid) - } - preferences.edit() - .putStringSet( - PREF_KNOWN_INCOMING_TXIDS, - knownTxids - ) - .apply() + val saved = + preferences.edit() + .putStringSet( + PREF_KNOWN_INCOMING_TXIDS, + knownTxids.toSet() + ) + .commit() + + if (saved) { + showIncomingPaymentNotification( + transaction.netSatoshis, + transaction.txid + ) + } + } } private fun showIncomingPaymentNotification( - receivedSatoshis: Long + receivedSatoshis: Long, + txid: String ) { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -446,7 +492,7 @@ class WalletMonitoringService : Service() { ) as NotificationManager manager.notify( - (System.currentTimeMillis() and 0x7fffffff).toInt(), + txid.hashCode(), notification ) } @@ -460,7 +506,8 @@ class WalletMonitoringService : Service() { utxo.outputIndex.toString(), utxo.value.toString(), utxo.height.toString(), - utxo.addressIndex.toString() + utxo.addressIndex.toString(), + utxo.isCoinbase.toString() ).joinToString("|") }.toSet() @@ -478,7 +525,7 @@ class WalletMonitoringService : Service() { try { val parts = value.split("|") - if (parts.size != 5) { + if (parts.size != 6) { return@mapNotNull null } @@ -487,7 +534,8 @@ class WalletMonitoringService : Service() { outputIndex = parts[1].toLong(), value = parts[2].toLong(), height = parts[3].toInt(), - addressIndex = parts[4].toInt() + addressIndex = parts[4].toInt(), + isCoinbase = parts[5].toBooleanStrict() ) } catch (_: Exception) { null