Release Vexta Wallet v0.6.0

This commit is contained in:
Vexta Project 2026-08-13 13:27:23 +00:00
commit 680a81cbe2
5 changed files with 833 additions and 13 deletions

View file

@ -22,8 +22,8 @@ android {
applicationId = "org.vextaproject.wallet"
minSdk = 26
targetSdk = 36
versionCode = 29
versionName = "0.5.1"
versionCode = 30
versionName = "0.6.0"
}
signingConfigs {
@ -55,6 +55,8 @@ android {
}
dependencies {
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("org.bitcoinj:bitcoinj-core:0.16.3")
implementation("com.google.zxing:core:3.5.3")
implementation(

View file

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<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" />
<application
android:allowBackup="false"
@ -29,6 +31,15 @@
android:noHistory="true"
android:excludeFromRecents="true" />
<service
android:name=".WalletMonitoringService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Monitors Vexta wallet for incoming blockchain payments while the app is in the background." />
</service>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />

View file

@ -0,0 +1,594 @@
package org.vextaproject.wallet
import android.content.Context
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.File
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import kotlin.random.Random
object HeaderSync {
private val MAGIC = byteArrayOf(0x56, 0x54, 0x58, 0x31)
private const val PORT = 19333
private const val PROTOCOL_VERSION = 70018
private const val GENESIS_HASH =
"0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1"
private const val GENESIS_TIME = 1782770400L
private const val GENESIS_BITS = 0x1e0ffff0L
private const val AVERAGING_WINDOW = 10
private const val TARGET_SPACING = 600L
private const val TARGET_TIMESPAN = AVERAGING_WINDOW * TARGET_SPACING
private const val MIN_TIMESPAN = TARGET_TIMESPAN * 92 / 100
private const val MAX_TIMESPAN = TARGET_TIMESPAN * 116 / 100
private val POW_LIMIT =
BigInteger.ONE.shiftLeft(256).subtract(BigInteger.ONE).shiftRight(20)
data class VersionInfo(
val protocol: Int,
val height: Int,
val userAgent: String
)
data class ChainHeader(
val height: Int,
val raw: ByteArray?,
val hashWire: ByteArray,
val hashDisplay: String,
val time: Long,
val bits: Long
)
data class SyncResult(
val received: Int,
val cachedBefore: Int,
val chainHeight: Int,
val lastHash: String
)
fun loadCachedChain(context: Context): MutableList<ChainHeader> {
val chain = mutableListOf(
ChainHeader(
height = 0,
raw = null,
hashWire = hashHexToWireBytes(GENESIS_HASH),
hashDisplay = GENESIS_HASH,
time = GENESIS_TIME,
bits = GENESIS_BITS
)
)
val file = File(context.filesDir, "vexta-headers.dat")
if (!file.exists()) {
return chain
}
val data = file.readBytes()
if (data.size % 80 != 0) {
file.delete()
return chain
}
try {
var offset = 0
while (offset < data.size) {
val raw = data.copyOfRange(offset, offset + 80)
appendVerifiedHeader(chain, raw)
offset += 80
}
} catch (_: Exception) {
file.delete()
return mutableListOf(chain.first())
}
return chain
}
fun saveChain(context: Context, chain: List<ChainHeader>) {
val file = File(context.filesDir, "vexta-headers.dat")
val temporary = File(context.filesDir, "vexta-headers.tmp")
temporary.outputStream().use { output ->
chain.drop(1).forEach { header ->
output.write(
header.raw
?: throw IllegalStateException("missing stored header")
)
}
}
if (file.exists()) {
file.delete()
}
if (!temporary.renameTo(file)) {
throw IllegalStateException("unable to save header database")
}
}
fun syncFromPeer(
host: String,
chain: MutableList<ChainHeader>
): SyncResult {
Socket().use { socket ->
socket.soTimeout = 15000
socket.connect(InetSocketAddress(host, PORT), 5000)
val input = DataInputStream(socket.getInputStream())
val output = socket.getOutputStream()
sendMessage(output, "version", createVersionPayload(host))
var versionInfo: VersionInfo? = null
var gotVerack = false
var requested = false
val cachedBefore = chain.last().height
repeat(200) {
val message = readMessage(input)
when (message.first) {
"version" -> {
versionInfo = parseVersionPayload(message.second)
sendMessage(output, "verack", byteArrayOf())
}
"verack" -> gotVerack = true
"ping" -> sendMessage(output, "pong", message.second)
"headers" -> {
val received = parseHeaders(message.second, chain)
val remote = versionInfo
?: throw IllegalStateException("missing node version")
if (chain.last().height > remote.height) {
throw IllegalStateException(
"local verified chain exceeds peer height"
)
}
return SyncResult(
received = received,
cachedBefore = cachedBefore,
chainHeight = chain.last().height,
lastHash = chain.last().hashDisplay
)
}
}
if (versionInfo != null && gotVerack && !requested) {
sendMessage(
output,
"getheaders",
createGetHeadersPayload(chain.last().hashWire)
)
requested = true
}
}
throw IllegalStateException("headers response timeout")
}
}
fun parseHeaders(
payload: ByteArray,
chain: MutableList<ChainHeader>
): Int {
val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
val count = readCompactSize(buffer)
if (count > 2000) {
throw IllegalStateException("too many headers: $count")
}
repeat(count) {
if (buffer.remaining() < 81) {
throw IllegalStateException("truncated headers message")
}
val raw = ByteArray(80)
buffer.get(raw)
val transactionCount = readCompactSize(buffer)
if (transactionCount != 0) {
throw IllegalStateException(
"headers message contains transactions"
)
}
appendVerifiedHeader(chain, raw)
}
return count
}
fun appendVerifiedHeader(
chain: MutableList<ChainHeader>,
raw: ByteArray
) {
val previous = chain.last()
val previousHash = raw.copyOfRange(4, 36)
if (!previousHash.contentEquals(previous.hashWire)) {
throw IllegalStateException(
"header linkage failed at height ${previous.height + 1}"
)
}
val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
val time = buffer.getInt(68).toLong() and 0xffffffffL
val bits = buffer.getInt(72).toLong() and 0xffffffffL
val target = compactToTarget(bits)
if (target <= BigInteger.ZERO || target > POW_LIMIT) {
throw IllegalStateException(
"invalid target at height ${previous.height + 1}"
)
}
val hashWire = doubleSha256(raw)
val hashNumber = BigInteger(1, hashWire.reversedArray())
if (hashNumber > target) {
throw IllegalStateException(
"proof of work failed at height ${previous.height + 1}"
)
}
val expectedBits = expectedBits(chain)
if (bits != expectedBits) {
throw IllegalStateException(
"difficulty mismatch at height ${previous.height + 1}: " +
"expected ${expectedBits.toString(16)}, " +
"received ${bits.toString(16)}"
)
}
chain.add(
ChainHeader(
height = previous.height + 1,
raw = raw,
hashWire = hashWire,
hashDisplay = hashWire.reversedArray().toHex(),
time = time,
bits = bits
)
)
}
fun expectedBits(chain: List<ChainHeader>): Long {
val last = chain.last()
if (last.height < AVERAGING_WINDOW) {
return targetToCompact(POW_LIMIT)
}
val first = chain[last.height - AVERAGING_WINDOW]
var actualTimespan =
medianTimePast(chain, last.height) -
medianTimePast(chain, first.height)
actualTimespan =
TARGET_TIMESPAN +
(actualTimespan - TARGET_TIMESPAN) / 4
actualTimespan = actualTimespan.coerceIn(
MIN_TIMESPAN,
MAX_TIMESPAN
)
var newTarget =
compactToTarget(last.bits)
.multiply(BigInteger.valueOf(actualTimespan))
.divide(BigInteger.valueOf(TARGET_TIMESPAN))
if (newTarget > POW_LIMIT) {
newTarget = POW_LIMIT
}
return targetToCompact(newTarget)
}
fun medianTimePast(
chain: List<ChainHeader>,
height: Int
): Long {
val start = maxOf(0, height - 10)
val times = (start..height)
.map { chain[it].time }
.sorted()
return times[times.size / 2]
}
fun compactToTarget(compact: Long): BigInteger {
val size = ((compact ushr 24) and 0xff).toInt()
var word = compact and 0x007fffffL
var value = BigInteger.valueOf(word)
value = if (size <= 3) {
value.shiftRight(8 * (3 - size))
} else {
value.shiftLeft(8 * (size - 3))
}
return if ((compact and 0x00800000L) != 0L) {
value.negate()
} else {
value
}
}
fun targetToCompact(targetValue: BigInteger): Long {
if (targetValue == BigInteger.ZERO) {
return 0
}
val negative = targetValue.signum() < 0
val target = targetValue.abs()
var size = (target.bitLength() + 7) / 8
var compact = if (size <= 3) {
target.toLong() shl (8 * (3 - size))
} else {
target.shiftRight(8 * (size - 3)).toLong()
}
if ((compact and 0x00800000L) != 0L) {
compact = compact ushr 8
size++
}
compact = compact and 0x007fffffL
compact = compact or (size.toLong() shl 24)
if (negative && (compact and 0x007fffffL) != 0L) {
compact = compact or 0x00800000L
}
return compact
}
fun createGetHeadersPayload(locatorHash: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
writeInt32LE(out, PROTOCOL_VERSION)
writeCompactSize(out, 1)
out.write(locatorHash)
out.write(ByteArray(32))
return out.toByteArray()
}
fun readMessage(input: DataInputStream): Pair<String, ByteArray> {
val header = ByteArray(24)
input.readFully(header)
if (!header.copyOfRange(0, 4).contentEquals(MAGIC)) {
throw IllegalStateException("invalid Vexta network magic")
}
val command = header.copyOfRange(4, 16)
.takeWhile { it.toInt() != 0 }
.toByteArray()
.toString(Charsets.US_ASCII)
val payloadLength = ByteBuffer.wrap(header, 16, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.int
if (payloadLength < 0 || payloadLength > 4_000_000) {
throw IllegalStateException("invalid payload length")
}
val expectedChecksum = header.copyOfRange(20, 24)
val payload = ByteArray(payloadLength)
input.readFully(payload)
val actualChecksum = doubleSha256(payload).copyOfRange(0, 4)
if (!expectedChecksum.contentEquals(actualChecksum)) {
throw IllegalStateException("invalid message checksum")
}
return command to payload
}
fun createVersionPayload(peerHost: String): ByteArray {
val out = ByteArrayOutputStream()
writeInt32LE(out, PROTOCOL_VERSION)
writeInt64LE(out, 0)
writeInt64LE(out, System.currentTimeMillis() / 1000)
writeNetworkAddress(out, peerHost, PORT)
writeNetworkAddress(out, "0.0.0.0", 0)
writeInt64LE(out, Random.nextLong())
writeVarString(out, "/VextaAndroidWallet:0.1.0/")
writeInt32LE(out, 0)
out.write(0)
return out.toByteArray()
}
fun parseVersionPayload(payload: ByteArray): VersionInfo {
val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
if (payload.size < 80) {
throw IllegalStateException("short version payload")
}
val version = buffer.int
buffer.long
buffer.long
buffer.position(buffer.position() + 26)
buffer.position(buffer.position() + 26)
buffer.long
val userAgent = readVarString(buffer)
val height = if (buffer.remaining() >= 4) buffer.int else 0
return VersionInfo(version, height, userAgent)
}
fun sendMessage(
output: OutputStream,
command: String,
payload: ByteArray
) {
val message = ByteArrayOutputStream()
message.write(MAGIC)
val commandBytes = ByteArray(12)
command.toByteArray(Charsets.US_ASCII).copyInto(
destination = commandBytes,
endIndex = minOf(command.length, 12)
)
message.write(commandBytes)
writeInt32LE(message, payload.size)
message.write(doubleSha256(payload).copyOfRange(0, 4))
message.write(payload)
output.write(message.toByteArray())
output.flush()
}
fun writeNetworkAddress(
out: ByteArrayOutputStream,
host: String,
port: Int
) {
writeInt64LE(out, 0)
val address = InetAddress.getByName(host).address
val ipv6 = ByteArray(16)
if (address.size == 4) {
ipv6[10] = 0xff.toByte()
ipv6[11] = 0xff.toByte()
address.copyInto(ipv6, 12)
} else {
address.copyInto(ipv6)
}
out.write(ipv6)
out.write((port shr 8) and 0xff)
out.write(port and 0xff)
}
fun writeVarString(out: ByteArrayOutputStream, value: String) {
val bytes = value.toByteArray(Charsets.UTF_8)
writeCompactSize(out, bytes.size)
out.write(bytes)
}
fun readVarString(buffer: ByteBuffer): String {
val length = readCompactSize(buffer)
if (length > buffer.remaining()) {
throw IllegalStateException("invalid string length")
}
val bytes = ByteArray(length)
buffer.get(bytes)
return bytes.toString(Charsets.UTF_8)
}
fun writeCompactSize(out: ByteArrayOutputStream, value: Int) {
when {
value < 253 -> out.write(value)
value <= 0xffff -> {
out.write(253)
out.write(value and 0xff)
out.write((value shr 8) and 0xff)
}
else -> {
out.write(254)
writeInt32LE(out, value)
}
}
}
fun readCompactSize(buffer: ByteBuffer): Int {
if (!buffer.hasRemaining()) {
throw IllegalStateException("missing compact size")
}
return when (val first = buffer.get().toInt() and 0xff) {
253 -> buffer.short.toInt() and 0xffff
254 -> buffer.int
255 -> {
val value = buffer.long
if (value < 0 || value > Int.MAX_VALUE) {
throw IllegalStateException("compact size too large")
}
value.toInt()
}
else -> first
}
}
fun hashHexToWireBytes(hash: String): ByteArray =
hash.removePrefix("0x")
.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
.reversedArray()
fun ByteArray.toHex(): String =
joinToString("") { "%02x".format(it.toInt() and 0xff) }
fun writeInt32LE(out: ByteArrayOutputStream, value: Int) {
out.write(
ByteBuffer.allocate(4)
.order(ByteOrder.LITTLE_ENDIAN)
.putInt(value)
.array()
)
}
fun writeInt64LE(out: ByteArrayOutputStream, value: Long) {
out.write(
ByteBuffer.allocate(8)
.order(ByteOrder.LITTLE_ENDIAN)
.putLong(value)
.array()
)
}
fun doubleSha256(data: ByteArray): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(digest.digest(data))
}
}

View file

@ -1,6 +1,9 @@
package org.vextaproject.wallet
import android.app.Activity
import androidx.fragment.app.FragmentActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import android.app.PendingIntent
import android.app.NotificationManager
import android.app.NotificationChannel
@ -66,7 +69,7 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class WalletActivity : Activity() {
class WalletActivity : FragmentActivity() {
companion object {
private const val PREFS = "vexta_wallet"
@ -124,11 +127,14 @@ class WalletActivity : Activity() {
if (
!isFinishing &&
!isDestroyed &&
hasWindowFocus() &&
walletExists() &&
walletBalanceView != null
!blockchainScanRunning
) {
startHeaderSyncAndScan()
if (hasWindowFocus()) {
startHeaderSyncAndScan()
} else {
startBackgroundHeaderSyncAndScan()
}
}
refreshHandler.postDelayed(this, 60_000L)
@ -238,6 +244,15 @@ class WalletActivity : Activity() {
createPaymentNotificationChannel()
requestNotificationPermission()
val monitoringIntent =
Intent(this, WalletMonitoringService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(monitoringIntent)
} else {
startService(monitoringIntent)
}
val filter = IntentFilter(
DownloadManager.ACTION_DOWNLOAD_COMPLETE
)
@ -254,7 +269,12 @@ class WalletActivity : Activity() {
}
if (walletExists()) {
showMainWallet()
authenticateWallet(
title = "Unlock Vexta Wallet",
subtitle = "Use biometrics or your device screen lock"
) {
showMainWallet()
}
} else {
showWelcome()
}
@ -680,6 +700,39 @@ class WalletActivity : Activity() {
}
}
private fun startBackgroundHeaderSyncAndScan() {
if (blockchainScanRunning) {
return
}
blockchainScanRunning = true
Thread {
try {
val chain = HeaderSync.loadCachedChain(this)
for (peer in listOf("87.106.99.23", "74.208.53.160")) {
try {
val result = HeaderSync.syncFromPeer(peer, chain)
if (result.received > 0) {
HeaderSync.saveChain(this, chain)
}
} catch (_: Exception) {
}
}
runOnUiThread {
runBlockchainScan()
}
} catch (_: Exception) {
runOnUiThread {
blockchainScanRunning = false
}
}
}.start()
}
private fun startHeaderSyncAndScan() {
if (blockchainScanRunning) {
return
@ -1878,8 +1931,12 @@ class WalletActivity : Activity() {
return@primaryButton
}
try {
val words = loadMnemonic()
authenticateWallet(
title = "Authorize payment",
subtitle = "Authenticate before signing this VTX transaction"
) {
try {
val words = loadMnemonic()
.trim()
.split(Regex("\\s+"))
@ -2093,6 +2150,7 @@ class WalletActivity : Activity() {
),
Toast.LENGTH_LONG
).show()
}
}
})
}
@ -2226,6 +2284,93 @@ class WalletActivity : Activity() {
}
}
private fun authenticateWallet(
title: String,
subtitle: String,
onSuccess: () -> Unit
) {
val authenticators =
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val biometricManager = BiometricManager.from(this)
if (
biometricManager.canAuthenticate(authenticators) !=
BiometricManager.BIOMETRIC_SUCCESS
) {
AlertDialog.Builder(this)
.setTitle("Device security required")
.setMessage(
"Vexta Wallet requires biometrics or a device " +
"PIN, pattern, or password. Configure a screen " +
"lock in Android security settings and try again."
)
.setNegativeButton("Cancel", null)
.setPositiveButton("Security settings") { _, _ ->
startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
)
}
.show()
return
}
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(
this,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(
this@WalletActivity,
"Authentication not recognized",
Toast.LENGTH_SHORT
).show()
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(
errorCode,
errString
)
if (
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
) {
Toast.makeText(
this@WalletActivity,
errString,
Toast.LENGTH_LONG
).show()
}
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(authenticators)
.build()
prompt.authenticate(promptInfo)
}
private fun confirmShowSeed() {
AlertDialog.Builder(this)
.setTitle("Security warning")
@ -2234,9 +2379,14 @@ class WalletActivity : Activity() {
"Make sure nobody can see your screen."
)
.setNegativeButton("Cancel", null)
.setPositiveButton("Show") { _, _ ->
val words = loadMnemonic().split(" ")
showSeedBackup(words)
.setPositiveButton("Authenticate") { _, _ ->
authenticateWallet(
title = "Show recovery phrase",
subtitle = "Authenticate to reveal your recovery phrase"
) {
val words = loadMnemonic().split(" ")
showSeedBackup(words)
}
}
.show()
}

View file

@ -0,0 +1,63 @@
package org.vextaproject.wallet
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
class WalletMonitoringService : Service() {
companion object {
private const val CHANNEL_ID = "vexta_wallet_monitoring"
private const val NOTIFICATION_ID = 7001
}
override fun onCreate() {
super.onCreate()
val manager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Wallet monitoring",
NotificationManager.IMPORTANCE_LOW
)
)
val intent = Intent(this, WalletActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Vexta Wallet")
.setContentText("Monitoring active")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
startForeground(NOTIFICATION_ID, notification)
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
}