Release Vexta Wallet v0.6.5

This commit is contained in:
Vexta Project 2026-08-25 18:25:43 +00:00
commit 6ee4b78298
5 changed files with 278 additions and 69 deletions

View file

@ -22,8 +22,8 @@ android {
applicationId = "org.vextaproject.wallet"
minSdk = 26
targetSdk = 36
versionCode = 34
versionName = "0.6.4"
versionCode = 35
versionName = "0.6.5"
}
signingConfigs {

View file

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
@ -40,6 +41,17 @@
android:value="Monitors Vexta wallet for incoming blockchain payments while the app is in the background." />
</service>
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />

View file

@ -0,0 +1,37 @@
package org.vextaproject.wallet
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (
intent?.action != Intent.ACTION_BOOT_COMPLETED &&
intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
val walletExists =
context.getSharedPreferences(
"vexta_wallet",
Context.MODE_PRIVATE
).contains("seed_ciphertext")
if (!walletExists) {
return
}
val monitoringIntent =
Intent(context, WalletMonitoringService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(monitoringIntent)
} else {
context.startService(monitoringIntent)
}
}
}

View file

@ -43,6 +43,39 @@ object VextaTransactionSender {
val spentOutpoints: Set<String>
)
data class MaxSpend(
val amount: Long,
val fee: Long
)
fun calculateMaxSpend(
spendableUtxos: List<BlockScanner.SpendableUtxo>
): MaxSpend {
require(spendableUtxos.isNotEmpty()) {
"Wallet has no spendable outputs"
}
val total = spendableUtxos.fold(0L) { sum, utxo ->
Math.addExact(sum, utxo.value)
}
val fee = estimateFee(
inputCount = spendableUtxos.size,
outputCount = 1
)
val amount = total - fee
require(amount > 0L) {
"Balance is too small to cover the transaction fee"
}
return MaxSpend(
amount = amount,
fee = fee
)
}
fun createAndSign(
spendableUtxos: List<BlockScanner.SpendableUtxo>,
recipientAddress: String,
@ -72,11 +105,27 @@ object VextaTransactionSender {
.thenBy { it.outputIndex }
)
val maxSpend = calculateMaxSpend(orderedUtxos)
val sendingMaximum =
amountSatoshis == maxSpend.amount
val selected =
mutableListOf<BlockScanner.SpendableUtxo>()
var selectedValue = 0L
var fee: Long
var change: Long
if (sendingMaximum) {
selected.addAll(orderedUtxos)
selectedValue = selected.fold(0L) { sum, utxo ->
Math.addExact(sum, utxo.value)
}
fee = maxSpend.fee
change = 0L
} else {
for (utxo in orderedUtxos) {
selected.add(utxo)
selectedValue =
@ -101,7 +150,7 @@ object VextaTransactionSender {
)
}
var fee = estimateFee(
fee = estimateFee(
inputCount = selected.size,
outputCount = 2
)
@ -112,8 +161,9 @@ object VextaTransactionSender {
)
}
var change =
change =
selectedValue - amountSatoshis - fee
}
val outputs = mutableListOf(
TransactionOutputData(
@ -122,6 +172,7 @@ object VextaTransactionSender {
)
)
if (!sendingMaximum) {
if (change >= dustLimit) {
outputs.add(
TransactionOutputData(
@ -133,6 +184,7 @@ object VextaTransactionSender {
fee += change
change = 0L
}
}
/*
* Network parameters do not affect transaction serialization or

View file

@ -267,12 +267,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
stopService(
Intent(
this@WalletActivity,
WalletMonitoringService::class.java
)
)
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@ -372,12 +366,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
stopService(
Intent(
this,
WalletMonitoringService::class.java
)
)
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@ -1404,10 +1392,10 @@ class WalletActivity : FragmentActivity() {
addView(sectionTitle("Get started"))
addView(space(10))
addView(primaryButton("Create new wallet") {
createNewWallet()
showSeedLengthDialog()
})
addView(space(12))
addView(secondaryButton("Restore from 12 words") {
addView(secondaryButton("Restore wallet") {
showRestoreDialog()
})
}
@ -1416,11 +1404,59 @@ class WalletActivity : FragmentActivity() {
setContentView(scroll(content))
}
private fun createNewWallet() {
val entropy = ByteArray(16)
private fun showSeedLengthDialog() {
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(
dp(20),
dp(8),
dp(20),
dp(4)
)
addView(
primaryButton("12-word recovery phrase") {
createNewWallet(12)
}
)
addView(space(12))
addView(
primaryButton("24-word recovery phrase") {
createNewWallet(24)
}
)
}
AlertDialog.Builder(this)
.setTitle("Recovery phrase length")
.setMessage(
"Choose the recovery phrase length for your new wallet."
)
.setView(content)
.setNegativeButton("Cancel", null)
.show()
}
private fun createNewWallet(wordCount: Int) {
val entropyBytes = when (wordCount) {
12 -> 16
24 -> 32
else -> throw IllegalArgumentException(
"Unsupported recovery phrase length"
)
}
val entropy = ByteArray(entropyBytes)
SecureRandom().nextBytes(entropy)
val words = MnemonicCode.INSTANCE.toMnemonic(entropy)
require(words.size == wordCount) {
"Unexpected recovery phrase length"
}
saveMnemonic(words.joinToString(" "))
getSharedPreferences(PREFS, MODE_PRIVATE)
@ -1441,7 +1477,7 @@ class WalletActivity : FragmentActivity() {
content.addView(title("Recovery phrase"))
content.addView(
subtitle(
"Write these 12 words down in the correct order.\n" +
"Write these ${words.size} words down in the correct order.\n" +
"Anyone with these words can control your VTX."
)
)
@ -1449,7 +1485,7 @@ class WalletActivity : FragmentActivity() {
content.addView(
card {
addView(sectionTitle("Your 12 words"))
addView(sectionTitle("Your ${words.size} words"))
addView(space(14))
addView(
@ -1674,12 +1710,6 @@ class WalletActivity : FragmentActivity() {
val now = System.currentTimeMillis()
if (now - lastExitSwipeAt <= 2_000L) {
stopService(
Intent(
this,
WalletMonitoringService::class.java
)
)
finishAndRemoveTask()
} else {
lastExitSwipeAt = now
@ -2005,6 +2035,80 @@ class WalletActivity : FragmentActivity() {
addView(space(12))
addView(amountInput)
addView(space(10))
val availableSatoshis =
latestSpendableUtxos.sumOf { it.value }
addView(
smallStatus(
"Available: " +
String.format(
java.util.Locale.US,
"%.8f VTX",
availableSatoshis.toDouble() /
100_000_000.0
) +
"\nNetwork fee is shown before sending."
)
)
addView(space(10))
addView(
secondaryButton("Send Max") {
try {
val maxSpend =
VextaTransactionSender.calculateMaxSpend(
latestSpendableUtxos
)
amountInput.setText(
String.format(
java.util.Locale.US,
"%.8f",
maxSpend.amount.toDouble() /
100_000_000.0
)
)
noteView.text =
"Send Max selected\n" +
"Available: " +
String.format(
java.util.Locale.US,
"%.8f VTX",
(
maxSpend.amount +
maxSpend.fee
).toDouble() /
100_000_000.0
) +
"\nNetwork fee: " +
String.format(
java.util.Locale.US,
"%.8f VTX",
maxSpend.fee.toDouble() /
100_000_000.0
) +
"\nSending: " +
String.format(
java.util.Locale.US,
"%.8f VTX",
maxSpend.amount.toDouble() /
100_000_000.0
)
} catch (error: Exception) {
Toast.makeText(
this@WalletActivity,
error.message
?: "Unable to calculate maximum amount",
Toast.LENGTH_LONG
).show()
}
}
)
addView(space(14))
addView(primaryButton("Review payment") {
if (sendInProgress) {
@ -2355,7 +2459,7 @@ class WalletActivity : FragmentActivity() {
private fun showRestoreDialog() {
val input = EditText(this).apply {
hint = "Enter the 12 recovery words"
hint = "Enter your 12 or 24 recovery words"
minLines = 5
gravity = Gravity.TOP
setTextColor(textPrimary)
@ -2370,7 +2474,9 @@ class WalletActivity : FragmentActivity() {
AlertDialog.Builder(this)
.setTitle("Restore Vexta Wallet")
.setMessage("Enter all 12 words separated by spaces.")
.setMessage(
"Enter all 12 or 24 recovery words separated by spaces."
)
.setView(input)
.setNegativeButton("Cancel", null)
.setPositiveButton("Restore") { _, _ ->
@ -2387,8 +2493,10 @@ class WalletActivity : FragmentActivity() {
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
if (words.size != 12) {
throw IllegalArgumentException("Exactly 12 words are required")
if (words.size != 12 && words.size != 24) {
throw IllegalArgumentException(
"Recovery phrase must contain 12 or 24 words"
)
}
MnemonicCode.INSTANCE.check(words)
@ -2527,7 +2635,7 @@ class WalletActivity : FragmentActivity() {
.setTitle("Delete wallet?")
.setMessage(
"This removes the encrypted seed from this phone. " +
"The wallet can only be recovered with its 12 words."
"The wallet can only be recovered with its recovery phrase."
)
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->