Release Vexta Wallet v0.6.7

This commit is contained in:
Vexta Project 2026-08-31 23:35:19 +00:00
commit 3e2a7150f9
5 changed files with 443 additions and 54 deletions

View file

@ -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 {

View file

@ -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<OutPoint>,
val outputs: List<TransactionOutput>
val outputs: List<TransactionOutput>,
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
)
}

View file

@ -48,9 +48,40 @@ object VextaTransactionSender {
val fee: Long
)
private const val COINBASE_MATURITY = 100
private fun validateCoinbaseMaturity(
utxos: List<BlockScanner.SpendableUtxo>,
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<BlockScanner.SpendableUtxo>
spendableUtxos: List<BlockScanner.SpendableUtxo>,
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<Int, ECKey>,
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

View file

@ -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<BlockScanner.SpendableUtxo>()
private var latestChainHeight = 0
private fun spendableUtxos(
utxos: List<BlockScanner.SpendableUtxo>,
chainHeight: Int
): List<BlockScanner.SpendableUtxo> =
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<String, Double>) -> 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<String, Double>()
val alternativesContainer =
LinearLayout(
this@WalletActivity
).apply {
orientation =
LinearLayout.VERTICAL
gravity = Gravity.CENTER
visibility = View.GONE
}
val alternativeViews =
mutableMapOf<String, TextView>()
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(

View file

@ -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 }
if (fullScanRequired) {
synchronizeKnownIncomingTransactions(
mergedTransactions
)
} else {
updateIncomingTransactionsAndNotify(
mergedTransactions
)
}
val cachedTransactions =
if (fullScanRequired) {
@ -332,6 +341,36 @@ class WalletMonitoringService : Service() {
}
}
private fun synchronizeKnownIncomingTransactions(
transactions: List<BlockScanner.WalletTransaction>
) {
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<BlockScanner.WalletTransaction>
) {
@ -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)
}
val saved =
preferences.edit()
.putStringSet(
PREF_KNOWN_INCOMING_TXIDS,
knownTxids
knownTxids.toSet()
)
.apply()
.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