diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efcc0a9..e206e75 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 = 34 - versionName = "0.6.4" + versionCode = 35 + versionName = "0.6.5" } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f056927..c75b2a1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + + + + + + + diff --git a/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt b/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt new file mode 100644 index 0000000..5cb07a9 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/BootReceiver.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt index e5aff19..542cb7e 100644 --- a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt +++ b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt @@ -43,6 +43,39 @@ object VextaTransactionSender { val spentOutpoints: Set ) + data class MaxSpend( + val amount: Long, + val fee: Long + ) + + fun calculateMaxSpend( + spendableUtxos: List + ): 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, recipientAddress: String, @@ -72,49 +105,66 @@ object VextaTransactionSender { .thenBy { it.outputIndex } ) + val maxSpend = calculateMaxSpend(orderedUtxos) + val sendingMaximum = + amountSatoshis == maxSpend.amount + val selected = mutableListOf() var selectedValue = 0L + var fee: Long + var change: Long - for (utxo in orderedUtxos) { - selected.add(utxo) - selectedValue = - Math.addExact(selectedValue, utxo.value) + if (sendingMaximum) { + selected.addAll(orderedUtxos) - val estimatedFee = estimateFee( + 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 = + Math.addExact(selectedValue, utxo.value) + + val estimatedFee = estimateFee( + inputCount = selected.size, + outputCount = 2 + ) + + if ( + selectedValue >= + amountSatoshis + estimatedFee + ) { + break + } + } + + if (selected.isEmpty()) { + throw IllegalStateException( + "Wallet has no spendable outputs" + ) + } + + fee = estimateFee( inputCount = selected.size, outputCount = 2 ) - if ( - selectedValue >= - amountSatoshis + estimatedFee - ) { - break + if (selectedValue < amountSatoshis + fee) { + throw IllegalStateException( + "Insufficient balance including transaction fee" + ) } + + change = + selectedValue - amountSatoshis - fee } - if (selected.isEmpty()) { - throw IllegalStateException( - "Wallet has no spendable outputs" - ) - } - - var fee = estimateFee( - inputCount = selected.size, - outputCount = 2 - ) - - if (selectedValue < amountSatoshis + fee) { - throw IllegalStateException( - "Insufficient balance including transaction fee" - ) - } - - var change = - selectedValue - amountSatoshis - fee - val outputs = mutableListOf( TransactionOutputData( value = amountSatoshis, @@ -122,16 +172,18 @@ object VextaTransactionSender { ) ) - if (change >= dustLimit) { - outputs.add( - TransactionOutputData( - value = change, - script = changeScript + if (!sendingMaximum) { + if (change >= dustLimit) { + outputs.add( + TransactionOutputData( + value = change, + script = changeScript + ) ) - ) - } else { - fee += change - change = 0L + } else { + fee += change + change = 0L + } } /* diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index fa03e49..35299f1 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -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") { _, _ ->