commit b5e3128628b5487cfb1bd80e813ab840b677ac93 Author: Vexta Project Date: Sun Aug 2 23:17:40 2026 +0000 Initial release of Vexta Wallet for Android diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93eef40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +.kotlin/ +local.properties +**/build/ +*.iml +.idea/ +*.jks +*.keystore +keystore.properties +app/build.gradle.kts.backup-* diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..e007374 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,11 @@ +# Privacy Policy + +Vexta Wallet is a non-custodial wallet. + +Recovery phrases and private keys remain on the users device. The recovery phrase is stored locally in encrypted form using Android Keystore protection. + +The wallet does not require an account and does not intentionally collect names, email addresses, phone numbers, or recovery phrases. + +The wallet connects to Vexta network nodes and the official update endpoint. Network operators may observe standard connection information such as IP address and connection time. + +Users are responsible for securely backing up their recovery phrase. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b4a18b --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Vexta Wallet for Android + +Official open-source Android wallet for the Vexta (VTX) blockchain. + +## Features + +- Create and restore a 12-word recovery phrase +- Multiple deterministic receiving addresses +- Native SegWit Vexta addresses +- Verified SPV block headers +- BIP158 compact-filter scanning +- Local transaction history +- Send and receive VTX +- QR-code scanning +- Encrypted recovery phrase storage +- Signed in-app updates with SHA256 verification + +## Requirements + +- Android 8.0 or newer +- Android SDK 36 +- Java 17 + +## Build + +Run: ./gradlew assembleDebug + +Debug APK: app/build/outputs/apk/debug/app-debug.apk + +## Official release signature + +Certificate SHA256: +12b3de2573af4a23fca222ee6609e60c6f685a624f510151128087a8c4d0020e + +## Official links + +- Website: https://vextaproject.org +- Explorer: https://vextaproject.org/explorer/ +- Discord: https://discord.gg/zzpm7ghN3e + +## Disclaimer + +Keep a secure offline backup of the recovery phrase. This software is provided without warranty. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ecaf093 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +Report security vulnerabilities privately through official Vexta Project contact channels. + +Never send recovery phrases, private keys, signing keystores, signing passwords, or screenshots containing recovery phrases. + +The Vexta Project will never ask for a recovery phrase or private key. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..eb428c6 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,63 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") +} + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() + +if (keystorePropertiesFile.exists()) { + FileInputStream(keystorePropertiesFile).use { + keystoreProperties.load(it) + } +} + +android { + namespace = "org.vextaproject.wallet" + compileSdk = 36 + + defaultConfig { + applicationId = "org.vextaproject.wallet" + minSdk = 26 + targetSdk = 36 + versionCode = 27 + versionName = "0.4.5" + } + + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + storeFile = file( + keystoreProperties.getProperty("storeFile") + ) + storePassword = + keystoreProperties.getProperty("storePassword") + keyAlias = + keystoreProperties.getProperty("keyAlias") + keyPassword = + keystoreProperties.getProperty("keyPassword") + } + } + } + + buildTypes { + getByName("release") { + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + + isMinifyEnabled = false + isShrinkResources = false + } + } +} + +dependencies { + implementation("org.bitcoinj:bitcoinj-core:0.16.3") + implementation("com.google.zxing:core:3.5.3") + implementation( + "com.google.android.gms:play-services-code-scanner:16.1.0" + ) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bd5ba98 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt new file mode 100644 index 0000000..3d815c1 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/BlockScanner.kt @@ -0,0 +1,746 @@ +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.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 BlockScanner { + + private val magic = byteArrayOf(0x56, 0x54, 0x58, 0x31) + private const val port = 19333 + private const val protocolVersion = 70018 + private const val msgWitnessBlock = 0x40000002 + + private const val genesisHash = + "0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1" + + data class SpendableUtxo( + val txid: String, + val outputIndex: Long, + val value: Long, + val height: Int, + val addressIndex: Int + ) + + data class WalletTransaction( + val txid: String, + val height: Int, + val netSatoshis: Long + ) + + data class Result( + val downloadedBlocks: Int, + val receivedTransactions: Int, + val spentTransactions: Int, + val utxoCount: Int, + val balanceSatoshis: Long, + val utxos: List, + val transactions: List + ) + + private data class Utxo( + val txidWire: ByteArray, + val outputIndex: Long, + val value: Long, + val height: Int, + val addressIndex: Int + ) + + private data class ParsedTransaction( + val txidWire: ByteArray, + val inputs: List, + val outputs: List + ) + + private data class OutPoint( + val txidWire: ByteArray, + val index: Long + ) + + private data class TransactionOutput( + val index: Int, + val value: Long, + val script: ByteArray + ) + + fun scan( + context: Context, + peer: String, + matchingHeights: List, + walletScript: ByteArray, + addressIndex: Int, + progress: (Int, Int) -> Unit + ): Result { + if (matchingHeights.isEmpty()) { + return Result( + downloadedBlocks = 0, + receivedTransactions = 0, + spentTransactions = 0, + utxoCount = 0, + balanceSatoshis = 0, + utxos = emptyList(), + transactions = emptyList() + ) + } + + val hashes = loadHeaderHashes(context) + val requested = matchingHeights + .distinct() + .sorted() + .associateWith { height -> + hashes.getOrNull(height) + ?: throw IllegalStateException( + "Missing verified header at height $height" + ) + } + + val heightByHash = requested.entries.associate { + it.value.toHex() to it.key + } + + val utxos = linkedMapOf() + var receivedTransactions = 0 + var spentTransactions = 0 + var downloadedBlocks = 0 + val walletTransactions = mutableListOf() + + Socket().use { socket -> + socket.soTimeout = 30000 + socket.connect(InetSocketAddress(peer, port), 5000) + + val input = DataInputStream(socket.getInputStream()) + val output = socket.getOutputStream() + + sendMessage(output, "version", createVersionPayload(peer)) + + var gotVersion = false + var gotVerack = false + var requestedBlocks = false + + repeat(100000) { + val (command, payload) = readMessage(input) + + when (command) { + "version" -> { + gotVersion = true + sendMessage(output, "verack", byteArrayOf()) + } + + "verack" -> gotVerack = true + + "ping" -> sendMessage(output, "pong", payload) + + "block" -> { + if (payload.size < 81) { + throw IllegalStateException("Short block payload") + } + + val blockHeader = payload.copyOfRange(0, 80) + val blockHashWire = doubleSha256(blockHeader) + val height = heightByHash[blockHashWire.toHex()] + ?: throw IllegalStateException( + "Received an unrequested block" + ) + + parseBlock(payload, walletScript).forEach { transaction -> + var spentWalletValue = 0L + + transaction.inputs.forEach { inputPoint -> + val key = outPointKey( + inputPoint.txidWire, + inputPoint.index + ) + + val spentOutput = utxos.remove(key) + + if (spentOutput != null) { + spentWalletValue += spentOutput.value + } + } + + if (spentWalletValue > 0L) { + spentTransactions++ + } + + var receivedWalletValue = 0L + + transaction.outputs.forEach { transactionOutput -> + if ( + transactionOutput.script.contentEquals( + walletScript + ) + ) { + val key = outPointKey( + transaction.txidWire, + transactionOutput.index.toLong() + ) + + utxos[key] = Utxo( + txidWire = transaction.txidWire, + outputIndex = + transactionOutput.index.toLong(), + value = transactionOutput.value, + height = height, + addressIndex = addressIndex + ) + + receivedWalletValue += + transactionOutput.value + } + } + + if (receivedWalletValue > 0L) { + receivedTransactions++ + } + + val netSatoshis = + receivedWalletValue - spentWalletValue + + if (netSatoshis != 0L) { + walletTransactions.add( + WalletTransaction( + txid = transaction.txidWire + .reversedArray() + .toHex(), + height = height, + netSatoshis = netSatoshis + ) + ) + } + } + + downloadedBlocks++ + progress(downloadedBlocks, requested.size) + + if (downloadedBlocks == requested.size) { + val spendable = utxos.values + .sortedWith( + compareBy { it.height } + .thenBy { it.txidWire.toHex() } + .thenBy { it.outputIndex } + ) + .map { utxo -> + SpendableUtxo( + txid = utxo.txidWire + .reversedArray() + .toHex(), + outputIndex = utxo.outputIndex, + value = utxo.value, + height = utxo.height, + addressIndex = utxo.addressIndex + ) + } + + return Result( + downloadedBlocks = downloadedBlocks, + receivedTransactions = receivedTransactions, + spentTransactions = spentTransactions, + utxoCount = spendable.size, + balanceSatoshis = spendable.sumOf { + it.value + }, + utxos = spendable, + transactions = walletTransactions + .sortedWith( + compareByDescending { + it.height + }.thenByDescending { + it.txid + } + ) + ) + } + } + + "notfound" -> { + throw IllegalStateException( + "Peer could not provide a requested block" + ) + } + } + + if ( + gotVersion && + gotVerack && + !requestedBlocks + ) { + sendMessage( + output, + "getdata", + createGetDataPayload(requested.values.toList()) + ) + + requestedBlocks = true + } + } + + throw IllegalStateException("Block download did not complete") + } + } + + private fun parseBlock( + payload: ByteArray, + walletScript: ByteArray + ): List { + val cursor = Cursor(payload) + cursor.skip(80) + + val transactionCount = cursor.readCompactSize() + + if (transactionCount > 1_000_000) { + throw IllegalStateException("Invalid transaction count") + } + + val transactions = ArrayList( + transactionCount.toInt() + ) + + repeat(transactionCount.toInt()) { + transactions.add(parseTransaction(cursor, walletScript)) + } + + if (!cursor.isFinished()) { + throw IllegalStateException("Unexpected bytes after block") + } + + return transactions + } + + private fun parseTransaction( + cursor: Cursor, + walletScript: ByteArray + ): ParsedTransaction { + val stripped = ByteArrayOutputStream() + + val version = cursor.readBytes(4) + stripped.write(version) + + var hasWitness = false + + if ( + cursor.remaining() >= 2 && + cursor.peekByte(0) == 0 && + cursor.peekByte(1) != 0 + ) { + cursor.skip(2) + hasWitness = true + } + + val inputCount = cursor.readCompactSize() + writeCompactSize(stripped, inputCount) + + val inputs = ArrayList(inputCount.toInt()) + + repeat(inputCount.toInt()) { + val previousTxid = cursor.readBytes(32) + val previousIndexBytes = cursor.readBytes(4) + val previousIndex = ByteBuffer + .wrap(previousIndexBytes) + .order(ByteOrder.LITTLE_ENDIAN) + .int + .toLong() and 0xffffffffL + + val scriptLength = cursor.readCompactSize() + val script = cursor.readBytes(scriptLength.toInt()) + val sequence = cursor.readBytes(4) + + stripped.write(previousTxid) + stripped.write(previousIndexBytes) + writeCompactSize(stripped, scriptLength) + stripped.write(script) + stripped.write(sequence) + + inputs.add( + OutPoint( + txidWire = previousTxid, + index = previousIndex + ) + ) + } + + val outputCount = cursor.readCompactSize() + writeCompactSize(stripped, outputCount) + + val outputs = ArrayList(outputCount.toInt()) + + repeat(outputCount.toInt()) { outputIndex -> + val valueBytes = cursor.readBytes(8) + val value = ByteBuffer + .wrap(valueBytes) + .order(ByteOrder.LITTLE_ENDIAN) + .long + + if (value < 0) { + throw IllegalStateException("Negative transaction output") + } + + val scriptLength = cursor.readCompactSize() + val script = cursor.readBytes(scriptLength.toInt()) + + stripped.write(valueBytes) + writeCompactSize(stripped, scriptLength) + stripped.write(script) + + if (script.contentEquals(walletScript)) { + outputs.add( + TransactionOutput( + index = outputIndex, + value = value, + script = script + ) + ) + } + } + + if (hasWitness) { + repeat(inputCount.toInt()) { + val itemCount = cursor.readCompactSize() + + repeat(itemCount.toInt()) { + val itemLength = cursor.readCompactSize() + cursor.skip(itemLength.toInt()) + } + } + } + + val lockTime = cursor.readBytes(4) + stripped.write(lockTime) + + return ParsedTransaction( + txidWire = doubleSha256(stripped.toByteArray()), + inputs = inputs, + outputs = outputs + ) + } + + private fun createGetDataPayload( + blockHashesWire: List + ): ByteArray { + val output = ByteArrayOutputStream() + + writeCompactSize(output, blockHashesWire.size.toLong()) + + blockHashesWire.forEach { hash -> + writeInt32LE(output, msgWitnessBlock) + output.write(hash) + } + + return output.toByteArray() + } + + private fun loadHeaderHashes(context: Context): List { + val hashes = mutableListOf( + hashHexToWireBytes(genesisHash) + ) + + val file = File(context.filesDir, "vexta-headers.dat") + + if (!file.exists()) { + return hashes + } + + val data = file.readBytes() + + if (data.size % 80 != 0) { + throw IllegalStateException( + "Corrupt local block-header database" + ) + } + + var offset = 0 + + while (offset < data.size) { + hashes.add( + doubleSha256( + data.copyOfRange(offset, offset + 80) + ) + ) + + offset += 80 + } + + return hashes + } + + private fun outPointKey( + txidWire: ByteArray, + index: Long + ): String = "${txidWire.toHex()}:$index" + + private fun createVersionPayload(peerHost: String): ByteArray { + val output = ByteArrayOutputStream() + + writeInt32LE(output, protocolVersion) + writeInt64LE(output, 0) + writeInt64LE(output, System.currentTimeMillis() / 1000) + + writeNetworkAddress(output, peerHost, port) + writeNetworkAddress(output, "0.0.0.0", 0) + + writeInt64LE(output, Random.nextLong()) + writeVarString(output, "/VextaAndroidWallet:0.4.3/") + writeInt32LE(output, 0) + output.write(0) + + return output.toByteArray() + } + + private fun readMessage( + input: DataInputStream + ): Pair { + 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 > 32_000_000) { + throw IllegalStateException("Invalid P2P 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 P2P checksum") + } + + return command to payload + } + + private fun sendMessage( + output: OutputStream, + command: String, + payload: ByteArray + ) { + val message = ByteArrayOutputStream() + message.write(magic) + + val commandBytes = ByteArray(12) + command.toByteArray(Charsets.US_ASCII).copyInto( + 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() + } + + private fun writeNetworkAddress( + output: ByteArrayOutputStream, + host: String, + port: Int + ) { + writeInt64LE(output, 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) + } + + output.write(ipv6) + output.write((port shr 8) and 0xff) + output.write(port and 0xff) + } + + private fun writeVarString( + output: ByteArrayOutputStream, + value: String + ) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeCompactSize(output, bytes.size.toLong()) + output.write(bytes) + } + + private fun writeCompactSize( + output: ByteArrayOutputStream, + value: Long + ) { + when { + value < 253 -> output.write(value.toInt()) + + value <= 0xffff -> { + output.write(253) + output.write((value and 0xff).toInt()) + output.write(((value shr 8) and 0xff).toInt()) + } + + value <= 0xffffffffL -> { + output.write(254) + + repeat(4) { index -> + output.write( + ((value shr (8 * index)) and 0xff).toInt() + ) + } + } + + else -> { + output.write(255) + + repeat(8) { index -> + output.write( + ((value shr (8 * index)) and 0xff).toInt() + ) + } + } + } + } + + private fun hashHexToWireBytes(hash: String): ByteArray = + hash.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + + private fun ByteArray.toHex(): String = + joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + + private fun writeInt32LE( + output: ByteArrayOutputStream, + value: Int + ) { + output.write( + ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(value) + .array() + ) + } + + private fun writeInt64LE( + output: ByteArrayOutputStream, + value: Long + ) { + output.write( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(value) + .array() + ) + } + + private fun doubleSha256(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(digest.digest(data)) + } + + private class Cursor( + private val data: ByteArray + ) { + private var offset = 0 + + fun remaining(): Int = data.size - offset + + fun isFinished(): Boolean = offset == data.size + + fun peekByte(relativeOffset: Int): Int { + require(offset + relativeOffset < data.size) { + "Transaction data is truncated" + } + + return data[offset + relativeOffset].toInt() and 0xff + } + + fun readBytes(length: Int): ByteArray { + require(length >= 0 && offset + length <= data.size) { + "Transaction data is truncated" + } + + val result = data.copyOfRange(offset, offset + length) + offset += length + return result + } + + fun skip(length: Int) { + require(length >= 0 && offset + length <= data.size) { + "Transaction data is truncated" + } + + offset += length + } + + fun readCompactSize(): Long { + val first = readBytes(1)[0].toInt() and 0xff + + return when (first) { + in 0..252 -> first.toLong() + + 253 -> { + val bytes = readBytes(2) + + (bytes[0].toLong() and 0xff) or + ((bytes[1].toLong() and 0xff) shl 8) + } + + 254 -> { + val bytes = readBytes(4) + var value = 0L + + repeat(4) { index -> + value = value or ( + (bytes[index].toLong() and 0xff) shl + (8 * index) + ) + } + + value + } + + else -> { + val bytes = readBytes(8) + var value = 0L + + repeat(8) { index -> + value = value or ( + (bytes[index].toLong() and 0xff) shl + (8 * index) + ) + } + + require(value >= 0) { + "CompactSize is too large" + } + + value + } + } + } + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt b/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt new file mode 100644 index 0000000..dfa0143 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt @@ -0,0 +1,557 @@ +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.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 CompactFilterClient { + + private val magic = byteArrayOf(0x56, 0x54, 0x58, 0x31) + private const val port = 19333 + private const val protocolVersion = 70018 + private const val compactFiltersService = 1L shl 6 + private const val maxFiltersPerRequest = 1000 + + private const val genesisHash = + "0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1" + + data class AvailabilityResult( + val peer: String, + val servicesCompactFilters: Boolean, + val filterHashesReceived: Int + ) + + data class ScanResult( + val peer: String, + val scannedFilters: Int, + val matchingHeights: List, + val localHeaderHeight: Int + ) + + fun check(peer: String): AvailabilityResult { + Socket().use { socket -> + socket.soTimeout = 15000 + socket.connect(InetSocketAddress(peer, port), 5000) + + val input = DataInputStream(socket.getInputStream()) + val output = socket.getOutputStream() + + sendMessage(output, "version", createVersionPayload(peer)) + + var compactFiltersAvailable = false + var gotVersion = false + var gotVerack = false + var requested = false + + repeat(100) { + val (command, payload) = readMessage(input) + + when (command) { + "version" -> { + val services = parseServices(payload) + compactFiltersAvailable = + (services and compactFiltersService) != 0L + + gotVersion = true + sendMessage(output, "verack", byteArrayOf()) + } + + "verack" -> gotVerack = true + "ping" -> sendMessage(output, "pong", payload) + + "cfheaders" -> { + if (payload.size < 66) { + throw IllegalStateException( + "short cfheaders payload" + ) + } + + val buffer = ByteBuffer.wrap(payload) + .order(ByteOrder.LITTLE_ENDIAN) + + val filterType = buffer.get().toInt() and 0xff + + if (filterType != 0) { + throw IllegalStateException( + "unexpected filter type $filterType" + ) + } + + buffer.position(buffer.position() + 64) + val count = readCompactSize(buffer) + + return AvailabilityResult( + peer, + compactFiltersAvailable, + count + ) + } + } + + if ( + gotVersion && + gotVerack && + compactFiltersAvailable && + !requested + ) { + sendMessage( + output, + "getcfheaders", + createGetCfHeadersPayload() + ) + requested = true + } + + if ( + gotVersion && + gotVerack && + !compactFiltersAvailable + ) { + return AvailabilityResult(peer, false, 0) + } + } + + throw IllegalStateException("compact filter response timeout") + } + } + + fun scan( + context: Context, + peer: String, + scriptPubKey: ByteArray, + progress: (Int, Int) -> Unit + ): ScanResult { + val headerHashes = loadHeaderHashes(context) + val localHeight = headerHashes.lastIndex + + if (localHeight < 1) { + throw IllegalStateException( + "No locally verified block headers available" + ) + } + + Socket().use { socket -> + socket.soTimeout = 30000 + socket.connect(InetSocketAddress(peer, port), 5000) + + val input = DataInputStream(socket.getInputStream()) + val output = socket.getOutputStream() + + sendMessage(output, "version", createVersionPayload(peer)) + + var compactFiltersAvailable = false + var gotVersion = false + var gotVerack = false + var nextHeight = 1 + var requestedEndHeight = 0 + var receivedInBatch = 0 + var totalScanned = 0 + val matches = mutableListOf() + + repeat(100000) { + val (command, payload) = readMessage(input) + + when (command) { + "version" -> { + val services = parseServices(payload) + compactFiltersAvailable = + (services and compactFiltersService) != 0L + + gotVersion = true + sendMessage(output, "verack", byteArrayOf()) + } + + "verack" -> gotVerack = true + "ping" -> sendMessage(output, "pong", payload) + + "cfilter" -> { + val filter = parseCFilter(payload) + val expectedHeight = nextHeight + receivedInBatch + val expectedHash = headerHashes[expectedHeight] + + if (!filter.blockHashWire.contentEquals(expectedHash)) { + throw IllegalStateException( + "Unexpected filter block hash at height " + + expectedHeight + ) + } + + if ( + GcsFilter.matches( + filter.encodedFilter, + filter.blockHashWire, + scriptPubKey + ) + ) { + matches.add(expectedHeight) + } + + receivedInBatch++ + totalScanned++ + progress(totalScanned, localHeight) + + if ( + nextHeight + receivedInBatch - 1 == + requestedEndHeight + ) { + nextHeight = requestedEndHeight + 1 + receivedInBatch = 0 + + if (nextHeight > localHeight) { + return ScanResult( + peer, + totalScanned, + matches, + localHeight + ) + } + + requestedEndHeight = minOf( + nextHeight + maxFiltersPerRequest - 1, + localHeight + ) + + sendMessage( + output, + "getcfilters", + createGetCFiltersPayload( + nextHeight, + headerHashes[requestedEndHeight] + ) + ) + } + } + } + + if ( + gotVersion && + gotVerack && + compactFiltersAvailable && + requestedEndHeight == 0 + ) { + requestedEndHeight = minOf( + maxFiltersPerRequest, + localHeight + ) + + sendMessage( + output, + "getcfilters", + createGetCFiltersPayload( + nextHeight, + headerHashes[requestedEndHeight] + ) + ) + } + + if ( + gotVersion && + gotVerack && + !compactFiltersAvailable + ) { + throw IllegalStateException( + "Peer does not provide compact filters" + ) + } + } + + throw IllegalStateException("filter scan did not complete") + } + } + + private data class ParsedCFilter( + val blockHashWire: ByteArray, + val encodedFilter: ByteArray + ) + + private fun parseCFilter(payload: ByteArray): ParsedCFilter { + if (payload.size < 34) { + throw IllegalStateException("short cfilter payload") + } + + val filterType = payload[0].toInt() and 0xff + + if (filterType != 0) { + throw IllegalStateException( + "unexpected filter type $filterType" + ) + } + + val blockHashWire = payload.copyOfRange(1, 33) + val buffer = ByteBuffer.wrap(payload) + .order(ByteOrder.LITTLE_ENDIAN) + + buffer.position(33) + val filterLength = readCompactSize(buffer) + + if (filterLength < 0 || filterLength > buffer.remaining()) { + throw IllegalStateException("invalid cfilter length") + } + + val encodedFilter = ByteArray(filterLength) + buffer.get(encodedFilter) + + return ParsedCFilter(blockHashWire, encodedFilter) + } + + private fun loadHeaderHashes(context: Context): List { + val result = mutableListOf( + hashHexToWireBytes(genesisHash) + ) + + val file = File(context.filesDir, "vexta-headers.dat") + + if (!file.exists()) { + return result + } + + val data = file.readBytes() + + if (data.size % 80 != 0) { + throw IllegalStateException("Corrupt local header database") + } + + var offset = 0 + + while (offset < data.size) { + val header = data.copyOfRange(offset, offset + 80) + result.add(doubleSha256(header)) + offset += 80 + } + + return result + } + + private fun createGetCFiltersPayload( + startHeight: Int, + stopHashWire: ByteArray + ): ByteArray { + val output = ByteArrayOutputStream() + + output.write(0) + writeInt32LE(output, startHeight) + output.write(stopHashWire) + + return output.toByteArray() + } + + private fun createGetCfHeadersPayload(): ByteArray { + val output = ByteArrayOutputStream() + + output.write(0) + writeInt32LE(output, 0) + output.write(hashHexToWireBytes(genesisHash)) + + return output.toByteArray() + } + + private fun parseServices(payload: ByteArray): Long { + if (payload.size < 12) { + throw IllegalStateException("short version payload") + } + + return ByteBuffer.wrap(payload) + .order(ByteOrder.LITTLE_ENDIAN) + .apply { int } + .long + } + + private fun createVersionPayload(peerHost: String): ByteArray { + val output = ByteArrayOutputStream() + + writeInt32LE(output, protocolVersion) + writeInt64LE(output, 0) + writeInt64LE(output, System.currentTimeMillis() / 1000) + + writeNetworkAddress(output, peerHost, port) + writeNetworkAddress(output, "0.0.0.0", 0) + + writeInt64LE(output, Random.nextLong()) + writeVarString(output, "/VextaAndroidWallet:0.1.0/") + writeInt32LE(output, 0) + output.write(0) + + return output.toByteArray() + } + + private fun readMessage( + input: DataInputStream + ): Pair { + 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 + } + + private fun sendMessage( + output: OutputStream, + command: String, + payload: ByteArray + ) { + val message = ByteArrayOutputStream() + + message.write(magic) + + val commandBytes = ByteArray(12) + command.toByteArray(Charsets.US_ASCII).copyInto( + 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() + } + + private fun writeNetworkAddress( + output: ByteArrayOutputStream, + host: String, + port: Int + ) { + writeInt64LE(output, 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) + } + + output.write(ipv6) + output.write((port shr 8) and 0xff) + output.write(port and 0xff) + } + + private fun writeVarString( + output: ByteArrayOutputStream, + value: String + ) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeCompactSize(output, bytes.size) + output.write(bytes) + } + + private fun writeCompactSize( + output: ByteArrayOutputStream, + value: Int + ) { + when { + value < 253 -> output.write(value) + + value <= 0xffff -> { + output.write(253) + output.write(value and 0xff) + output.write((value shr 8) and 0xff) + } + + else -> { + output.write(254) + writeInt32LE(output, value) + } + } + } + + private fun readCompactSize(buffer: ByteBuffer): Int { + if (!buffer.hasRemaining()) { + throw IllegalStateException("missing CompactSize") + } + + 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( + "CompactSize too large" + ) + } + + value.toInt() + } + + else -> first + } + } + + private fun hashHexToWireBytes(hash: String): ByteArray = + hash.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + + private fun writeInt32LE( + output: ByteArrayOutputStream, + value: Int + ) { + output.write( + ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(value) + .array() + ) + } + + private fun writeInt64LE( + output: ByteArrayOutputStream, + value: Long + ) { + output.write( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(value) + .array() + ) + } + + private fun doubleSha256(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(digest.digest(data)) + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/GcsFilter.kt b/app/src/main/java/org/vextaproject/wallet/GcsFilter.kt new file mode 100644 index 0000000..da6f4ae --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/GcsFilter.kt @@ -0,0 +1,312 @@ +package org.vextaproject.wallet + +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.ByteOrder + +object GcsFilter { + + private const val P = 19 + private const val M = 784931L + + fun matches( + encodedFilter: ByteArray, + blockHashWire: ByteArray, + element: ByteArray + ): Boolean { + require(blockHashWire.size == 32) { + "Block hash must contain 32 bytes" + } + + val compactSize = readCompactSize(encodedFilter, 0) + val count = compactSize.first + + if (count == 0L) { + return false + } + + if (count > Int.MAX_VALUE) { + throw IllegalArgumentException("Filter contains too many elements") + } + + val key0 = readInt64LE(blockHashWire, 0) + val key1 = readInt64LE(blockHashWire, 8) + + val range = BigInteger.valueOf(count) + .multiply(BigInteger.valueOf(M)) + + val sipHash = sipHash24(key0, key1, element) + val query = mapIntoRange(sipHash, range) + + val reader = BitReader( + encodedFilter, + compactSize.second + ) + + var value = BigInteger.ZERO + + repeat(count.toInt()) { + val quotient = reader.readUnary() + val remainder = reader.readBits(P) + + val delta = BigInteger.valueOf(quotient) + .shiftLeft(P) + .add(BigInteger.valueOf(remainder)) + + value = value.add(delta) + + val comparison = value.compareTo(query) + + if (comparison == 0) { + return true + } + + if (comparison > 0) { + return false + } + } + + return false + } + + private fun mapIntoRange( + hash: Long, + range: BigInteger + ): BigInteger { + val unsignedHash = if (hash >= 0) { + BigInteger.valueOf(hash) + } else { + BigInteger.valueOf(hash and Long.MAX_VALUE) + .setBit(63) + } + + return unsignedHash + .multiply(range) + .shiftRight(64) + } + + private fun readCompactSize( + data: ByteArray, + offset: Int + ): Pair { + if (offset >= data.size) { + throw IllegalArgumentException("Missing CompactSize") + } + + val first = data[offset].toInt() and 0xff + + return when (first) { + in 0..252 -> first.toLong() to offset + 1 + + 253 -> { + require(offset + 3 <= data.size) { + "Truncated CompactSize" + } + + val value = + (data[offset + 1].toLong() and 0xff) or + ((data[offset + 2].toLong() and 0xff) shl 8) + + value to offset + 3 + } + + 254 -> { + require(offset + 5 <= data.size) { + "Truncated CompactSize" + } + + var value = 0L + + for (index in 0 until 4) { + value = value or ( + (data[offset + 1 + index].toLong() and 0xff) shl + (8 * index) + ) + } + + value to offset + 5 + } + + else -> { + require(offset + 9 <= data.size) { + "Truncated CompactSize" + } + + var value = 0L + + for (index in 0 until 8) { + value = value or ( + (data[offset + 1 + index].toLong() and 0xff) shl + (8 * index) + ) + } + + require(value >= 0) { + "CompactSize exceeds signed Long range" + } + + value to offset + 9 + } + } + } + + private fun readInt64LE( + data: ByteArray, + offset: Int + ): Long { + return ByteBuffer.wrap(data, offset, 8) + .order(ByteOrder.LITTLE_ENDIAN) + .long + } + + private fun sipHash24( + key0: Long, + key1: Long, + data: ByteArray + ): Long { + var v0 = key0 xor 0x736f6d6570736575L + var v1 = key1 xor 0x646f72616e646f6dL + var v2 = key0 xor 0x6c7967656e657261L + var v3 = key1 xor 0x7465646279746573L + + var offset = 0 + + while (offset + 8 <= data.size) { + val message = readInt64LE(data, offset) + v3 = v3 xor message + + repeat(2) { + val result = sipRound(v0, v1, v2, v3) + v0 = result[0] + v1 = result[1] + v2 = result[2] + v3 = result[3] + } + + v0 = v0 xor message + offset += 8 + } + + var finalBlock = data.size.toLong() shl 56 + var shift = 0 + + while (offset < data.size) { + finalBlock = finalBlock or ( + (data[offset].toLong() and 0xff) shl shift + ) + + shift += 8 + offset++ + } + + v3 = v3 xor finalBlock + + repeat(2) { + val result = sipRound(v0, v1, v2, v3) + v0 = result[0] + v1 = result[1] + v2 = result[2] + v3 = result[3] + } + + v0 = v0 xor finalBlock + v2 = v2 xor 0xffL + + repeat(4) { + val result = sipRound(v0, v1, v2, v3) + v0 = result[0] + v1 = result[1] + v2 = result[2] + v3 = result[3] + } + + return v0 xor v1 xor v2 xor v3 + } + + private fun sipRound( + input0: Long, + input1: Long, + input2: Long, + input3: Long + ): LongArray { + var v0 = input0 + var v1 = input1 + var v2 = input2 + var v3 = input3 + + v0 += v1 + v1 = java.lang.Long.rotateLeft(v1, 13) + v1 = v1 xor v0 + v0 = java.lang.Long.rotateLeft(v0, 32) + + v2 += v3 + v3 = java.lang.Long.rotateLeft(v3, 16) + v3 = v3 xor v2 + + v0 += v3 + v3 = java.lang.Long.rotateLeft(v3, 21) + v3 = v3 xor v0 + + v2 += v1 + v1 = java.lang.Long.rotateLeft(v1, 17) + v1 = v1 xor v2 + v2 = java.lang.Long.rotateLeft(v2, 32) + + return longArrayOf(v0, v1, v2, v3) + } + + private class BitReader( + private val data: ByteArray, + startOffset: Int + ) { + private var byteOffset = startOffset + private var bitOffset = 0 + + fun readUnary(): Long { + var value = 0L + + while (readBit()) { + value++ + + if (value > Int.MAX_VALUE) { + throw IllegalArgumentException( + "Invalid Golomb-Rice quotient" + ) + } + } + + return value + } + + fun readBits(count: Int): Long { + var value = 0L + + repeat(count) { + value = (value shl 1) or + if (readBit()) 1L else 0L + } + + return value + } + + private fun readBit(): Boolean { + if (byteOffset >= data.size) { + throw IllegalArgumentException( + "Unexpected end of GCS filter" + ) + } + + val bit = + (data[byteOffset].toInt() ushr (7 - bitOffset)) and 1 + + bitOffset++ + + if (bitOffset == 8) { + bitOffset = 0 + byteOffset++ + } + + return bit == 1 + } + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/MainActivity.kt b/app/src/main/java/org/vextaproject/wallet/MainActivity.kt new file mode 100644 index 0000000..ee51d1f --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/MainActivity.kt @@ -0,0 +1,719 @@ +package org.vextaproject.wallet + +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +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 + +class MainActivity : Activity() { + + private lateinit var status: TextView + + companion object { + 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 + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent.getBooleanExtra("sync_only", false)) { + window.setBackgroundDrawableResource( + android.R.color.transparent + ) + startSync() + return + } + + val layout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(48, 100, 48, 100) + setBackgroundColor(Color.rgb(7, 24, 46)) + } + + val title = TextView(this).apply { + text = "Vexta Wallet" + textSize = 32f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + } + + status = TextView(this).apply { + text = "\nLoading and verifying cached headers..." + textSize = 16f + setTextColor(Color.LTGRAY) + gravity = Gravity.CENTER + } + + layout.addView(title) + layout.addView(status) + + setContentView(ScrollView(this).apply { addView(layout) }) + + startSync() + } + + private fun startSync() { + Thread { + try { + val chain = loadCachedChain() + val cachedAtStart = chain.last().height + + val peers = listOf( + "87.106.99.23", + "74.208.53.160" + ) + + val results = mutableListOf() + + for ((index, peer) in peers.withIndex()) { + try { + val result = syncFromPeer(peer, chain) + + if (result.received > 0) { + saveChain(chain) + } + + results.add( + "$peer:$PORT\n" + + "Handshake: successful\n" + + "Headers received: ${result.received}\n" + + "Cached before: ${result.cachedBefore}\n" + + "Verified height: ${result.chainHeight}\n" + + "Proof of work: valid\n" + + "Difficulty rules: valid\n" + + "Chain linkage: valid\n\n" + + "Tip hash:\n${result.lastHash}" + ) + } catch (e: Exception) { + results.add( + "$peer:$PORT\nFailed: ${e.message ?: e.javaClass.simpleName}" + ) + } + } + + runOnUiThread { + if (intent.getBooleanExtra("sync_only", false)) { + setResult( + RESULT_OK, + android.content.Intent().putExtra( + "verified_height", + chain.last().height + ) + ) + finish() + } else { + status.text = + "\nVexta SPV verification\n\n" + + "Initial cached height: $cachedAtStart\n" + + "Stored header file: " + + "${chain.last().height} blocks\n\n" + + results.joinToString( + "\n\n--------------------\n\n" + ) + } + } + } catch (e: Exception) { + runOnUiThread { + if (intent.getBooleanExtra("sync_only", false)) { + setResult(RESULT_CANCELED) + finish() + } else { + status.text = + "\nVerification failed\n\n" + + (e.message ?: e.javaClass.simpleName) + } + } + } + }.start() + } + + private fun loadCachedChain(): MutableList { + val chain = mutableListOf( + ChainHeader( + height = 0, + raw = null, + hashWire = hashHexToWireBytes(GENESIS_HASH), + hashDisplay = GENESIS_HASH, + time = GENESIS_TIME, + bits = GENESIS_BITS + ) + ) + + val file = File(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 + } + + private fun saveChain(chain: List) { + val file = File(filesDir, "vexta-headers.dat") + val temporary = File(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") + } + } + + private fun syncFromPeer( + host: String, + chain: MutableList + ): 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") + } + } + + private fun parseHeaders( + payload: ByteArray, + chain: MutableList + ): 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 + } + + private fun appendVerifiedHeader( + chain: MutableList, + 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 + ) + ) + } + + private fun expectedBits(chain: List): 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) + } + + private fun medianTimePast( + chain: List, + height: Int + ): Long { + val start = maxOf(0, height - 10) + val times = (start..height) + .map { chain[it].time } + .sorted() + + return times[times.size / 2] + } + + private 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 + } + } + + private 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 + } + + private 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() + } + + private fun readMessage(input: DataInputStream): Pair { + 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 + } + + private 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() + } + + private 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) + } + + private 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() + } + + private 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) + } + + private fun writeVarString(out: ByteArrayOutputStream, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeCompactSize(out, bytes.size) + out.write(bytes) + } + + private 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) + } + + private 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) + } + } + } + + private 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 + } + } + + private fun hashHexToWireBytes(hash: String): ByteArray = + hash.removePrefix("0x") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + + private fun ByteArray.toHex(): String = + joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun writeInt32LE(out: ByteArrayOutputStream, value: Int) { + out.write( + ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(value) + .array() + ) + } + + private fun writeInt64LE(out: ByteArrayOutputStream, value: Long) { + out.write( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(value) + .array() + ) + } + + private fun doubleSha256(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(digest.digest(data)) + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt new file mode 100644 index 0000000..a89fce4 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/VextaTransactionSender.kt @@ -0,0 +1,922 @@ +package org.vextaproject.wallet + +import org.bitcoinj.core.ECKey +import org.bitcoinj.script.ScriptBuilder +import org.bitcoinj.params.MainNetParams +import org.bitcoinj.core.TransactionWitness +import org.bitcoinj.core.Utils +import org.bitcoinj.core.TransactionOutput +import org.bitcoinj.core.TransactionOutPoint +import org.bitcoinj.core.TransactionInput +import org.bitcoinj.core.Transaction +import org.bitcoinj.core.Coin +import org.bitcoinj.core.Sha256Hash +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import kotlin.random.Random + +object VextaTransactionSender { + + private val magic = byteArrayOf(0x56, 0x54, 0x58, 0x31) + private const val port = 19333 + private const val protocolVersion = 70018 + private const val sighashAll = 1L + private const val sequence = 0xffffffffL + private const val dustLimit = 546L + + data class CreatedTransaction( + val rawTransaction: ByteArray, + val txid: String, + val amount: Long, + val fee: Long, + val change: Long, + val inputCount: Int, + val spentOutpoints: Set + ) + + fun createAndSign( + spendableUtxos: List, + recipientAddress: String, + amountSatoshis: Long, + privateKeysByIndex: Map, + changePubKeyHash: ByteArray + ): CreatedTransaction { + require(amountSatoshis > 0L) { + "Amount must be greater than zero" + } + + require(changePubKeyHash.size == 20) { + "Invalid change public-key hash" + } + + require(privateKeysByIndex.isNotEmpty()) { + "No wallet private keys supplied" + } + + val recipientScript = addressToScript(recipientAddress) + val changeScript = + byteArrayOf(0x00, 0x14) + changePubKeyHash + + val orderedUtxos = spendableUtxos.sortedWith( + compareBy { it.height } + .thenBy { it.txid } + .thenBy { it.outputIndex } + ) + + val selected = + mutableListOf() + + var selectedValue = 0L + + 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" + ) + } + + 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, + script = recipientScript + ) + ) + + if (change >= dustLimit) { + outputs.add( + TransactionOutputData( + value = change, + script = changeScript + ) + ) + } else { + fee += change + change = 0L + } + + /* + * Network parameters do not affect transaction serialization or + * SegWit signature hashing. Vexta uses Bitcoin-compatible transaction + * and witness serialization, so bitcoinj's transaction implementation + * can safely perform the BIP143 signature calculation. + */ + val parameters = MainNetParams.get() + val transaction = Transaction(parameters) + transaction.setVersion(2) + + selected.forEach { utxo -> + val outPoint = TransactionOutPoint( + parameters, + utxo.outputIndex, + Sha256Hash.wrap(utxo.txid) + ) + + val input = TransactionInput( + parameters, + transaction, + byteArrayOf(), + outPoint, + Coin.valueOf(utxo.value) + ) + + input.sequenceNumber = 0xffffffffL + transaction.addInput(input) + } + + outputs.forEach { output -> + transaction.addOutput( + TransactionOutput( + parameters, + transaction, + Coin.valueOf(output.value), + output.script + ) + ) + } + + selected.forEachIndexed { index, utxo -> + val privateKey = + privateKeysByIndex[utxo.addressIndex] + ?: throw IllegalStateException( + "Missing private key for address index " + + utxo.addressIndex + ) + + val walletPubKeyHash = + Utils.sha256hash160(privateKey.pubKey) + + val scriptCode = + ScriptBuilder.createP2PKHOutputScript( + walletPubKeyHash + ) + + val signature = + transaction.calculateWitnessSignature( + index, + privateKey, + scriptCode, + Coin.valueOf(utxo.value), + Transaction.SigHash.ALL, + false + ) + + transaction.getInput(index.toLong()).setWitness( + TransactionWitness.redeemP2WPKH( + signature, + privateKey + ) + ) + } + + val rawTransaction = + transaction.bitcoinSerialize() + + return CreatedTransaction( + rawTransaction = rawTransaction, + txid = transaction.txId.toString(), + amount = amountSatoshis, + fee = fee, + change = change, + inputCount = selected.size, + spentOutpoints = selected.map { + "${it.txid}:${it.outputIndex}" + }.toSet() + ) + } + + fun broadcast( + transaction: CreatedTransaction, + peers: List + ): Int { + var successfulPeers = 0 + var lastError: Exception? = null + + peers.distinct().forEach { peer -> + try { + broadcastToPeer(peer, transaction.rawTransaction) + successfulPeers++ + } catch (error: Exception) { + lastError = error + } + } + + if (successfulPeers == 0) { + throw lastError + ?: IllegalStateException("Transaction broadcast failed") + } + + return successfulPeers + } + + private fun serializeTransaction( + selected: List, + outputs: List, + signatures: List, + publicKey: ByteArray, + includeWitness: Boolean + ): ByteArray { + val output = ByteArrayOutputStream() + + writeInt32LE(output, 2) + + if (includeWitness) { + output.write(0x00) + output.write(0x01) + } + + writeCompactSize(output, selected.size.toLong()) + + selected.forEach { utxo -> + output.write(hashDisplayToWire(utxo.txid)) + writeUInt32LE(output, utxo.outputIndex) + output.write(0x00) + writeUInt32LE(output, sequence) + } + + output.write(serializeOutputs(outputs)) + + if (includeWitness) { + selected.indices.forEach { index -> + output.write(0x02) + + val signature = signatures[index] + writeCompactSize(output, signature.size.toLong()) + output.write(signature) + + writeCompactSize(output, publicKey.size.toLong()) + output.write(publicKey) + } + } + + writeUInt32LE(output, 0) + return output.toByteArray() + } + + private fun serializeOutputs( + outputs: List + ): ByteArray { + return ByteArrayOutputStream().apply { + writeCompactSize(this, outputs.size.toLong()) + + outputs.forEach { transactionOutput -> + writeInt64LE(this, transactionOutput.value) + writeCompactSize( + this, + transactionOutput.script.size.toLong() + ) + write(transactionOutput.script) + } + }.toByteArray() + } + + private fun estimateFee( + inputCount: Int, + outputCount: Int + ): Long { + val virtualBytes = + 11L + + inputCount * 69L + + outputCount * 31L + + return maxOf( + 1_000L, + virtualBytes * 10L + ) + } + + private fun addressToScript(address: String): ByteArray { + val value = address.trim() + + if (value.lowercase().startsWith("vtx1")) { + val witnessProgram = decodeBech32Witness(value) + + require(witnessProgram.size == 20) { + "Only Vexta P2WPKH addresses are supported" + } + + return byteArrayOf(0x00, 0x14) + witnessProgram + } + + val decoded = decodeBase58Check(value) + + require(decoded.size == 21) { + "Invalid legacy Vexta address" + } + + require((decoded[0].toInt() and 0xff) == 70) { + "Wrong legacy Vexta address prefix" + } + + val publicKeyHash = decoded.copyOfRange(1, 21) + + return byteArrayOf( + 0x76, + 0xa9.toByte(), + 0x14 + ) + + publicKeyHash + + byteArrayOf( + 0x88.toByte(), + 0xac.toByte() + ) + } + + private fun decodeBech32Witness(address: String): ByteArray { + require(address == address.lowercase()) { + "Mixed-case Bech32 address" + } + + val separator = address.lastIndexOf('1') + + require(separator > 0 && separator + 7 <= address.length) { + "Invalid Bech32 address" + } + + val hrp = address.substring(0, separator) + require(hrp == "vtx") { + "Wrong Bech32 network" + } + + val charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + val values = address.substring(separator + 1).map { character -> + val index = charset.indexOf(character) + require(index >= 0) { + "Invalid Bech32 character" + } + index + } + + require(bech32Polymod(expandHrp(hrp) + values) == 1) { + "Invalid Bech32 checksum" + } + + val data = values.dropLast(6) + require(data.isNotEmpty() && data[0] == 0) { + "Unsupported witness version" + } + + return convertBits( + data.drop(1), + 5, + 8, + false + ).map { it.toByte() }.toByteArray() + } + + private fun convertBits( + input: List, + fromBits: Int, + toBits: Int, + pad: Boolean + ): List { + var accumulator = 0 + var bits = 0 + val result = mutableListOf() + val maxValue = (1 shl toBits) - 1 + val maxAccumulator = + (1 shl (fromBits + toBits - 1)) - 1 + + input.forEach { value -> + require(value >= 0 && value shr fromBits == 0) { + "Invalid address data" + } + + accumulator = + ((accumulator shl fromBits) or value) and + maxAccumulator + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result.add( + (accumulator shr bits) and maxValue + ) + } + } + + if (pad) { + if (bits > 0) { + result.add( + (accumulator shl (toBits - bits)) and + maxValue + ) + } + } else { + require(bits < fromBits) { + "Invalid address padding" + } + + require( + bits == 0 || + ((accumulator shl (toBits - bits)) and + maxValue) == 0 + ) { + "Non-zero address padding" + } + } + + return result + } + + private fun expandHrp(hrp: String): List = + hrp.map { it.code shr 5 } + + listOf(0) + + hrp.map { it.code and 31 } + + private fun bech32Polymod(values: List): Int { + val generators = intArrayOf( + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3 + ) + + var checksum = 1 + + values.forEach { value -> + val top = checksum ushr 25 + checksum = + ((checksum and 0x1ffffff) shl 5) xor value + + for (index in 0..4) { + if (((top shr index) and 1) != 0) { + checksum = + checksum xor generators[index] + } + } + } + + return checksum + } + + private fun decodeBase58Check(value: String): ByteArray { + val alphabet = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + var number = java.math.BigInteger.ZERO + val base = java.math.BigInteger.valueOf(58) + + value.forEach { character -> + val digit = alphabet.indexOf(character) + + require(digit >= 0) { + "Invalid Base58 character" + } + + number = number + .multiply(base) + .add(java.math.BigInteger.valueOf(digit.toLong())) + } + + val rawNumber = number.toByteArray().let { bytes -> + if (bytes.size > 1 && bytes[0].toInt() == 0) { + bytes.copyOfRange(1, bytes.size) + } else { + bytes + } + } + + val leadingZeros = value.takeWhile { it == '1' }.length + val decoded = ByteArray(leadingZeros) + rawNumber + + require(decoded.size >= 5) { + "Invalid Base58 address" + } + + val payload = decoded.copyOfRange(0, decoded.size - 4) + val checksum = decoded.copyOfRange( + decoded.size - 4, + decoded.size + ) + + require( + doubleSha256(payload) + .copyOfRange(0, 4) + .contentEquals(checksum) + ) { + "Invalid Base58 checksum" + } + + return payload + } + + private fun broadcastToPeer( + peer: String, + rawTransaction: ByteArray + ) { + Socket().use { socket -> + socket.soTimeout = 5_000 + socket.connect( + InetSocketAddress(peer, port), + 5_000 + ) + + val input = DataInputStream(socket.getInputStream()) + val output = socket.getOutputStream() + + sendMessage( + output, + "version", + createVersionPayload(peer) + ) + + var gotVersion = false + var gotVerack = false + var transactionSent = false + + repeat(100) { + try { + val message = readMessage(input) + + when (message.first) { + "version" -> { + gotVersion = true + sendMessage( + output, + "verack", + byteArrayOf() + ) + } + + "verack" -> gotVerack = true + + "ping" -> sendMessage( + output, + "pong", + message.second + ) + + "reject" -> { + throw IllegalStateException( + parseRejectMessage(message.second) + ) + } + + "inv" -> { + if (transactionSent) { + return + } + } + } + + if ( + gotVersion && + gotVerack && + !transactionSent + ) { + sendMessage( + output, + "tx", + rawTransaction + ) + transactionSent = true + socket.soTimeout = 5_000 + } + } catch (_: SocketTimeoutException) { + if (transactionSent) { + return + } + + throw IllegalStateException( + "Peer handshake timed out" + ) + } + } + + if (!transactionSent) { + throw IllegalStateException( + "Peer handshake did not complete" + ) + } + } + } + + private fun parseRejectMessage( + payload: ByteArray + ): String { + return try { + val buffer = ByteBuffer.wrap(payload) + .order(ByteOrder.LITTLE_ENDIAN) + + val rejectedCommand = + readRejectVarString(buffer) + + val code = + if (buffer.hasRemaining()) { + buffer.get().toInt() and 0xff + } else { + -1 + } + + val reason = + if (buffer.hasRemaining()) { + readRejectVarString(buffer) + } else { + "unknown reason" + } + + "Peer rejected $rejectedCommand " + + "(code $code): $reason" + } catch (_: Exception) { + "Peer rejected transaction" + } + } + + private fun readRejectVarString( + buffer: ByteBuffer + ): String { + val length = readRejectCompactSize(buffer) + + require( + length >= 0L && + length <= buffer.remaining().toLong() && + length <= 10_000L + ) { + "Invalid reject message" + } + + val bytes = ByteArray(length.toInt()) + buffer.get(bytes) + return bytes.toString(Charsets.UTF_8) + } + + private fun readRejectCompactSize( + buffer: ByteBuffer + ): Long { + val first = buffer.get().toInt() and 0xff + + return when (first) { + 253 -> buffer.short.toInt().and(0xffff).toLong() + 254 -> buffer.int.toLong().and(0xffffffffL) + 255 -> buffer.long + else -> first.toLong() + } + } + + private fun createVersionPayload( + peerHost: String + ): ByteArray { + return ByteArrayOutputStream().apply { + writeInt32LE(this, protocolVersion) + writeInt64LE(this, 0) + writeInt64LE( + this, + System.currentTimeMillis() / 1000 + ) + writeNetworkAddress(this, peerHost, port) + writeNetworkAddress(this, "0.0.0.0", 0) + writeInt64LE(this, Random.nextLong()) + writeVarString( + this, + "/VextaAndroidWallet:0.4.3/" + ) + writeInt32LE(this, 0) + write(0) + }.toByteArray() + } + + private fun sendMessage( + output: OutputStream, + command: String, + payload: ByteArray + ) { + val message = ByteArrayOutputStream() + message.write(magic) + + val commandBytes = ByteArray(12) + command.toByteArray(Charsets.US_ASCII).copyInto( + 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() + } + + private fun readMessage( + input: DataInputStream + ): Pair { + val header = ByteArray(24) + input.readFully(header) + + require( + header.copyOfRange(0, 4) + .contentEquals(magic) + ) { + "Invalid Vexta network message" + } + + 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 + + require( + payloadLength in 0..4_000_000 + ) { + "Invalid P2P payload length" + } + + val payload = ByteArray(payloadLength) + input.readFully(payload) + + val checksum = header.copyOfRange(20, 24) + + require( + doubleSha256(payload) + .copyOfRange(0, 4) + .contentEquals(checksum) + ) { + "Invalid P2P checksum" + } + + return command to payload + } + + private fun writeNetworkAddress( + output: ByteArrayOutputStream, + host: String, + targetPort: Int + ) { + writeInt64LE(output, 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) + } + + output.write(ipv6) + output.write((targetPort shr 8) and 0xff) + output.write(targetPort and 0xff) + } + + private fun writeVarString( + output: ByteArrayOutputStream, + value: String + ) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeCompactSize(output, bytes.size.toLong()) + output.write(bytes) + } + + private fun writeCompactSize( + output: ByteArrayOutputStream, + value: Long + ) { + when { + value < 253 -> output.write(value.toInt()) + + value <= 0xffff -> { + output.write(253) + output.write((value and 0xff).toInt()) + output.write( + ((value shr 8) and 0xff).toInt() + ) + } + + value <= 0xffffffffL -> { + output.write(254) + repeat(4) { index -> + output.write( + ((value shr (8 * index)) and 0xff) + .toInt() + ) + } + } + + else -> { + output.write(255) + repeat(8) { index -> + output.write( + ((value shr (8 * index)) and 0xff) + .toInt() + ) + } + } + } + } + + private fun writeInt32LE( + output: ByteArrayOutputStream, + value: Int + ) { + output.write( + ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(value) + .array() + ) + } + + private fun writeUInt32LE( + output: ByteArrayOutputStream, + value: Long + ) { + repeat(4) { index -> + output.write( + ((value shr (8 * index)) and 0xff) + .toInt() + ) + } + } + + private fun writeInt64LE( + output: ByteArrayOutputStream, + value: Long + ) { + output.write( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(value) + .array() + ) + } + + private fun hashDisplayToWire( + hash: String + ): ByteArray { + require(hash.length == 64) { + "Invalid transaction ID" + } + + return hash.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + } + + private fun doubleSha256( + data: ByteArray + ): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(digest.digest(data)) + } + + private fun ByteArray.toHex(): String = + joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + + private data class TransactionOutputData( + val value: Long, + val script: ByteArray + ) +} diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt new file mode 100644 index 0000000..ab86743 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -0,0 +1,2911 @@ +package org.vextaproject.wallet + +import android.app.Activity +import android.app.PendingIntent +import android.app.NotificationManager +import android.app.NotificationChannel +import android.app.Notification +import android.Manifest +import android.app.DownloadManager +import android.app.AlertDialog +import android.content.ClipData +import android.content.BroadcastReceiver +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.text.InputType +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.Button +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import org.bitcoinj.core.Base58 +import org.json.JSONObject +import org.bitcoinj.core.ECKey +import org.bitcoinj.core.Sha256Hash +import org.bitcoinj.core.Utils +import org.bitcoinj.crypto.ChildNumber +import org.bitcoinj.crypto.DeterministicHierarchy +import org.bitcoinj.crypto.HDKeyDerivation +import org.bitcoinj.crypto.MnemonicCode +import java.security.KeyStore +import java.security.MessageDigest +import java.math.BigDecimal +import java.math.RoundingMode +import java.net.HttpURLConnection +import java.net.URL +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class WalletActivity : Activity() { + + companion object { + private const val PREFS = "vexta_wallet" + private const val KEY_ALIAS = "vexta_wallet_seed_key" + private const val PREF_CIPHERTEXT = "seed_ciphertext" + private const val PREF_IV = "seed_iv" + private const val PREF_BACKUP_CONFIRMED = "backup_confirmed" + private const val UPDATE_URL = + "https://vexta-pool.co.uk/vexta-wallet-version.json" + private const val REQUEST_HEADER_SYNC = 4101 + private const val REQUEST_NOTIFICATIONS = 4102 + private const val PAYMENT_CHANNEL_ID = + "vexta_incoming_payments" + private const val PREF_LAST_CONFIRMED_BALANCE = + "last_confirmed_balance" + private const val PREF_BALANCE_INITIALIZED = + "balance_notification_initialized" + private const val PREF_RECEIVE_ADDRESS_INDEX = + "receive_address_index" + private const val PREF_RESTORE_ADDRESS_DISCOVERY = + "restore_address_discovery" + private const val RESTORE_ADDRESS_LOOKAHEAD = 20 + } + + private val backgroundColor = Color.rgb(2, 8, 23) + private val surfaceColor = Color.rgb(8, 22, 45) + private val surfaceColorAlt = Color.rgb(13, 38, 67) + private val accentColor = Color.rgb(0, 210, 255) + private val accentDark = Color.rgb(0, 119, 255) + private val neonPurple = Color.rgb(127, 90, 240) + private val successColor = Color.rgb(36, 220, 151) + private val textPrimary = Color.rgb(242, 248, 255) + private val textSecondary = Color.rgb(143, 168, 200) + private val dangerColor = Color.rgb(255, 73, 106) + private var sendRecipientInput: EditText? = null + private var blockchainScanStatus: TextView? = null + private var walletBalanceView: TextView? = null + private var walletHistoryView: TextView? = null + private var latestWalletTransactions = + emptyList() + private var transactionHistoryFilter = "ALL" + private var transactionHistoryLimit = 50 + private var transactionHistoryVisible = false + private var mainWalletVisible = false + private var automaticScanStarted = false + private var blockchainScanRunning = false + private var sendInProgress = false + private var latestSpendableUtxos = + emptyList() + + private val refreshHandler = Handler(Looper.getMainLooper()) + + private val refreshRunnable = object : Runnable { + override fun run() { + if ( + !isFinishing && + !isDestroyed && + hasWindowFocus() && + walletExists() && + walletBalanceView != null + ) { + startHeaderSyncAndScan() + } + + refreshHandler.postDelayed(this, 60_000L) + } + } + private var updateDownloadId = -1L + private var pendingUpdateUri: Uri? = null + private var pendingUpdateSha256: String? = null + + private val updateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if ( + intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE + ) { + return + } + + val completedId = intent.getLongExtra( + DownloadManager.EXTRA_DOWNLOAD_ID, + -1L + ) + + if (completedId != updateDownloadId) { + return + } + + val manager = getSystemService( + DOWNLOAD_SERVICE + ) as DownloadManager + + val query = DownloadManager.Query() + .setFilterById(completedId) + + manager.query(query)?.use { cursor -> + if (!cursor.moveToFirst()) { + Toast.makeText( + this@WalletActivity, + "Downloaded update could not be found", + Toast.LENGTH_LONG + ).show() + return + } + + val statusIndex = cursor.getColumnIndex( + DownloadManager.COLUMN_STATUS + ) + + val status = cursor.getInt(statusIndex) + + if (status != DownloadManager.STATUS_SUCCESSFUL) { + Toast.makeText( + this@WalletActivity, + "Update download failed", + Toast.LENGTH_LONG + ).show() + return + } + + val downloadedUri = + manager.getUriForDownloadedFile(completedId) + + if (downloadedUri == null) { + Toast.makeText( + this@WalletActivity, + "Downloaded update could not be opened", + Toast.LENGTH_LONG + ).show() + return + } + + val expectedSha256 = + pendingUpdateSha256?.trim()?.lowercase() + + if ( + expectedSha256.isNullOrBlank() || + !verifyDownloadedUpdate( + downloadedUri, + expectedSha256 + ) + ) { + manager.remove(completedId) + pendingUpdateUri = null + pendingUpdateSha256 = null + + AlertDialog.Builder(this@WalletActivity) + .setTitle("Update verification failed") + .setMessage( + "The downloaded APK does not match the " + + "official SHA256 checksum and was deleted. " + + "The update will not be installed." + ) + .setPositiveButton("Close", null) + .show() + + return + } + + pendingUpdateUri = downloadedUri + openDownloadedUpdate() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + createPaymentNotificationChannel() + requestNotificationPermission() + + val filter = IntentFilter( + DownloadManager.ACTION_DOWNLOAD_COMPLETE + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + updateReceiver, + filter, + Context.RECEIVER_NOT_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver(updateReceiver, filter) + } + + if (walletExists()) { + showMainWallet() + } else { + showWelcome() + } + + checkForUpdates() + } + + override fun onResume() { + super.onResume() + + if ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + packageManager.canRequestPackageInstalls() + ) { + if (pendingUpdateUri != null) { + openDownloadedUpdate() + } + } + + if ( + walletExists() && + mainWalletVisible && + !blockchainScanRunning + ) { + window.decorView.postDelayed({ + if ( + !isFinishing && + !isDestroyed && + mainWalletVisible && + !blockchainScanRunning + ) { + startHeaderSyncAndScan() + } + }, 400L) + } + } + + override fun onDestroy() { + refreshHandler.removeCallbacks(refreshRunnable) + + try { + unregisterReceiver(updateReceiver) + } catch (_: IllegalArgumentException) { + } + + super.onDestroy() + } + + private fun createPaymentNotificationChannel() { + val manager = + getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + val channel = NotificationChannel( + PAYMENT_CHANNEL_ID, + "Incoming VTX payments", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = + "Notifications when new confirmed VTX payments arrive" + enableVibration(true) + } + + manager.createNotificationChannel(channel) + } + + private fun requestNotificationPermission() { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_NOTIFICATIONS + ) + } + } + + private fun updateBalanceAndNotify( + confirmedBalanceSatoshis: Long + ) { + val preferences = + getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + val initialized = preferences.getBoolean( + PREF_BALANCE_INITIALIZED, + false + ) + + val previousBalance = preferences.getLong( + PREF_LAST_CONFIRMED_BALANCE, + confirmedBalanceSatoshis + ) + + preferences.edit() + .putBoolean(PREF_BALANCE_INITIALIZED, true) + .putLong( + PREF_LAST_CONFIRMED_BALANCE, + confirmedBalanceSatoshis + ) + .apply() + + if ( + !initialized || + confirmedBalanceSatoshis <= previousBalance + ) { + return + } + + val receivedSatoshis = + confirmedBalanceSatoshis - previousBalance + + showIncomingPaymentNotification(receivedSatoshis) + } + + private fun showIncomingPaymentNotification( + receivedSatoshis: Long + ) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + val amount = String.format( + "%.8f", + receivedSatoshis.toDouble() / 100_000_000.0 + ) + + val openWalletIntent = Intent( + this, + WalletActivity::class.java + ).apply { + flags = + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + openWalletIntent, + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE + ) + + val notification = Notification.Builder( + this, + PAYMENT_CHANNEL_ID + ) + .setSmallIcon(android.R.drawable.stat_notify_more) + .setContentTitle("VTX payment received") + .setContentText("$amount VTX has been confirmed") + .setStyle( + Notification.BigTextStyle().bigText( + "A payment of $amount VTX was received " + + "and confirmed in your Vexta Wallet." + ) + ) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setCategory(Notification.CATEGORY_MESSAGE) + .build() + + val manager = + getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + manager.notify( + (System.currentTimeMillis() and 0x7fffffff).toInt(), + notification + ) + } + + private fun checkForUpdates() { + Thread { + try { + val connection = ( + URL(UPDATE_URL).openConnection() + as HttpURLConnection + ).apply { + connectTimeout = 8000 + readTimeout = 8000 + requestMethod = "GET" + useCaches = false + } + + val responseCode = connection.responseCode + + if (responseCode !in 200..299) { + connection.disconnect() + return@Thread + } + + val response = connection.inputStream + .bufferedReader() + .use { it.readText() } + + connection.disconnect() + + val json = JSONObject(response) + val latestCode = json.getLong("versionCode") + val latestName = json.optString( + "versionName", + latestCode.toString() + ) + val apkUrl = json.optString( + "apkUrl", + json.optString("downloadUrl") + ) + + require(apkUrl.isNotBlank()) { + "Missing update download URL" + } + val sha256 = json.getString("sha256") + .trim() + .lowercase() + + require( + sha256.matches(Regex("^[0-9a-f]{64}$")) + ) { + "Invalid update SHA256" + } + + val notes = json.optString( + "notes", + json.optString( + "releaseNotes", + "A new Vexta Wallet version is available." + ) + ) + + val currentCode = if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ) { + packageManager.getPackageInfo( + packageName, + 0 + ).longVersionCode + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo( + packageName, + 0 + ).versionCode.toLong() + } + + if (latestCode <= currentCode) { + return@Thread + } + + runOnUiThread { + if (isFinishing || isDestroyed) { + return@runOnUiThread + } + + AlertDialog.Builder(this) + .setTitle("Wallet update available") + .setMessage( + "Version $latestName is available.\n\n" + + notes + ) + .setNegativeButton("Later", null) + .setPositiveButton("Update") { _, _ -> + downloadUpdate( + apkUrl, + latestName, + sha256 + ) + } + .show() + } + } catch (_: Exception) { + // Update checks must never prevent wallet startup. + } + }.start() + } + + private fun downloadUpdate( + apkUrl: String, + versionName: String, + expectedSha256: String + ) { + try { + val manager = getSystemService( + DOWNLOAD_SERVICE + ) as DownloadManager + + val request = DownloadManager.Request( + Uri.parse(apkUrl) + ).apply { + setTitle("Vexta Wallet $versionName") + setDescription("Downloading wallet update") + setMimeType( + "application/vnd.android.package-archive" + ) + setNotificationVisibility( + DownloadManager.Request + .VISIBILITY_VISIBLE_NOTIFY_COMPLETED + ) + setAllowedOverMetered(true) + setAllowedOverRoaming(false) + setDestinationInExternalFilesDir( + this@WalletActivity, + Environment.DIRECTORY_DOWNLOADS, + "vexta-wallet-update.apk" + ) + } + + pendingUpdateSha256 = + expectedSha256.trim().lowercase() + pendingUpdateUri = null + + updateDownloadId = manager.enqueue(request) + + Toast.makeText( + this, + "Downloading wallet update", + Toast.LENGTH_LONG + ).show() + } catch (error: Exception) { + Toast.makeText( + this, + "Could not start update: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + } + + private fun verifyDownloadedUpdate( + apkUri: Uri, + expectedSha256: String + ): Boolean { + return try { + val digest = MessageDigest.getInstance("SHA-256") + + contentResolver.openInputStream(apkUri)?.use { input -> + val buffer = ByteArray(64 * 1024) + + while (true) { + val count = input.read(buffer) + + if (count < 0) { + break + } + + if (count > 0) { + digest.update(buffer, 0, count) + } + } + } ?: return false + + val actualSha256 = digest.digest() + .joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + + actualSha256.equals( + expectedSha256, + ignoreCase = true + ) + } catch (_: Exception) { + false + } + } + + private fun openDownloadedUpdate() { + val apkUri = pendingUpdateUri ?: return + + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !packageManager.canRequestPackageInstalls() + ) { + AlertDialog.Builder(this) + .setTitle("Allow wallet updates") + .setMessage( + "Android must allow Vexta Wallet to install " + + "updates. Enable “Allow from this source”, " + + "then return to the wallet." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Open settings") { _, _ -> + startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:$packageName") + ) + ) + } + .show() + + return + } + + val installIntent = Intent( + Intent.ACTION_VIEW + ).apply { + setDataAndType( + apkUri, + "application/vnd.android.package-archive" + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + startActivity(installIntent) + pendingUpdateUri = null + pendingUpdateSha256 = null + } catch (error: Exception) { + Toast.makeText( + this, + "Could not open update installer: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + } + + private fun startHeaderSyncAndScan() { + if (blockchainScanRunning) { + return + } + + blockchainScanRunning = true + + blockchainScanStatus?.text = + "Synchronizing block headers..." + + startActivityForResult( + Intent( + this, + MainActivity::class.java + ).putExtra("sync_only", true), + REQUEST_HEADER_SYNC + ) + + overridePendingTransition(0, 0) + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ) { + if (requestCode == REQUEST_HEADER_SYNC) { + overridePendingTransition(0, 0) + + if (resultCode == RESULT_OK) { + val height = data?.getIntExtra( + "verified_height", + -1 + ) ?: -1 + + blockchainScanStatus?.text = if (height >= 0) { + "Headers synchronized to block $height.\n" + + "Scanning compact filters..." + } else { + "Headers synchronized.\n" + + "Scanning compact filters..." + } + + runBlockchainScan() + } else { + blockchainScanRunning = false + blockchainScanStatus?.text = + "Block-header synchronization failed." + } + + return + } + + super.onActivityResult(requestCode, resultCode, data) + } + + private fun currentReceiveAddressIndex(): Int { + return getSharedPreferences(PREFS, MODE_PRIVATE) + .getInt(PREF_RECEIVE_ADDRESS_INDEX, 0) + .coerceAtLeast(0) + } + + private fun setCurrentReceiveAddressIndex(index: Int) { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putInt(PREF_RECEIVE_ADDRESS_INDEX, index.coerceAtLeast(0)) + .apply() + } + + private fun runBlockchainScan() { + Thread { + try { + val words = loadMnemonic() + .trim() + .split(Regex("\\s+")) + + val preferences = + getSharedPreferences(PREFS, MODE_PRIVATE) + val restoreAddressDiscovery = + preferences.getBoolean( + PREF_RESTORE_ADDRESS_DISCOVERY, + false + ) + val highestAddressIndex = + if (restoreAddressDiscovery) { + maxOf( + currentReceiveAddressIndex(), + RESTORE_ADDRESS_LOOKAHEAD + ) + } else { + currentReceiveAddressIndex() + } + + var highestUsedAddressIndex = -1 + + val allUtxos = + mutableListOf() + val allTransactions = + mutableListOf() + + var totalFiltersScanned = 0 + var totalRelevantBlocks = 0 + var totalReceivedTransactions = 0 + var totalSpentTransactions = 0 + + for (addressIndex in 0..highestAddressIndex) { + val scriptPubKey = + deriveWitnessScript(words, addressIndex) + + runOnUiThread { + blockchainScanStatus?.text = + "Scanning address " + + "${addressIndex + 1} of " + + "${highestAddressIndex + 1}..." + } + + val filterResult = CompactFilterClient.scan( + this@WalletActivity, + "87.106.99.23", + scriptPubKey + ) { scanned, total -> + runOnUiThread { + blockchainScanStatus?.text = + "Address ${addressIndex + 1}/" + + "${highestAddressIndex + 1}\n" + + "Scanning compact filters: " + + "$scanned / $total" + } + } + + totalFiltersScanned += + filterResult.scannedFilters + + val blockResult = BlockScanner.scan( + this@WalletActivity, + "87.106.99.23", + filterResult.matchingHeights, + scriptPubKey, + addressIndex + ) { downloaded, total -> + runOnUiThread { + blockchainScanStatus?.text = + "Address ${addressIndex + 1}/" + + "${highestAddressIndex + 1}\n" + + "Relevant blocks downloaded: " + + "$downloaded / $total" + } + } + + allUtxos.addAll(blockResult.utxos) + allTransactions.addAll( + blockResult.transactions + ) + + if ( + blockResult.receivedTransactions > 0 || + blockResult.spentTransactions > 0 || + blockResult.transactions.isNotEmpty() + ) { + highestUsedAddressIndex = addressIndex + } + + totalRelevantBlocks += + blockResult.downloadedBlocks + totalReceivedTransactions += + blockResult.receivedTransactions + totalSpentTransactions += + blockResult.spentTransactions + } + + val mergedTransactions = + allTransactions + .groupBy { it.txid } + .map { (_, transactions) -> + BlockScanner.WalletTransaction( + txid = transactions.first().txid, + height = transactions.maxOf { + it.height + }, + netSatoshis = transactions.sumOf { + it.netSatoshis + } + ) + } + .filter { it.netSatoshis != 0L } + .sortedWith( + compareByDescending< + BlockScanner.WalletTransaction + > { + it.height + }.thenByDescending { + it.txid + } + ) + + val mergedUtxos = + allUtxos + .distinctBy { + "${it.txid}:${it.outputIndex}" + } + .sortedWith( + compareBy { + it.height + }.thenBy { + it.txid + }.thenBy { + it.outputIndex + } + ) + + val balanceSatoshis = + mergedUtxos.sumOf { it.value } + + val balanceText = String.format( + "%.8f VTX", + balanceSatoshis.toDouble() / + 100_000_000.0 + ) + + updateBalanceAndNotify(balanceSatoshis) + + if (restoreAddressDiscovery) { + setCurrentReceiveAddressIndex( + highestUsedAddressIndex.coerceAtLeast(0) + ) + preferences.edit() + .putBoolean( + PREF_RESTORE_ADDRESS_DISCOVERY, + false + ) + .apply() + } + + runOnUiThread { + latestSpendableUtxos = mergedUtxos + latestWalletTransactions = + mergedTransactions + + walletBalanceView?.text = balanceText + walletHistoryView?.text = + formatTransactionHistory( + mergedTransactions, + limit = 3, + showFullTxid = false + ) + + if (transactionHistoryVisible) { + showTransactionHistory() + } + + blockchainScanStatus?.text = + "Addresses scanned: " + + "${highestAddressIndex + 1}\n" + + "Filters scanned: $totalFiltersScanned\n" + + "Relevant blocks: $totalRelevantBlocks\n" + + "Received transactions: " + + "$totalReceivedTransactions\n" + + "Spent transactions: " + + "$totalSpentTransactions\n" + + "Unspent outputs: " + + "${mergedUtxos.size}\n" + + "Verified balance: $balanceText" + } + } catch (e: Exception) { + runOnUiThread { + blockchainScanStatus?.text = + "Blockchain scan failed:\n" + + (e.message ?: e.javaClass.simpleName) + } + } finally { + runOnUiThread { + blockchainScanRunning = false + } + } + }.start() + } + + private fun formatTransactionHistory( + transactions: List, + limit: Int, + showFullTxid: Boolean + ): String { + if (transactions.isEmpty()) { + return "No confirmed transactions yet" + } + + return transactions + .take(limit) + .joinToString("\n\n") { transaction -> + val received = transaction.netSatoshis > 0L + val amount = String.format( + "%.8f", + kotlin.math.abs( + transaction.netSatoshis + ).toDouble() / 100_000_000.0 + ) + + val direction = + if (received) "Received" else "Sent" + + val sign = + if (received) "+" else "-" + + val txidText = + if (showFullTxid) { + transaction.txid + } else { + transaction.txid.take(12) + + "..." + + transaction.txid.takeLast(12) + } + + "$direction $sign$amount VTX\n" + + "Block ${transaction.height}\n" + + txidText + } + } + + private fun showTransactionHistory() { + mainWalletVisible = false + transactionHistoryVisible = true + + val filteredTransactions = + when (transactionHistoryFilter) { + "RECEIVED" -> latestWalletTransactions.filter { + it.netSatoshis > 0L + } + + "SENT" -> latestWalletTransactions.filter { + it.netSatoshis < 0L + } + + else -> latestWalletTransactions + } + + val displayedTransactions = + filteredTransactions.take(transactionHistoryLimit) + + val content = baseLayout() + + content.addView(space(10)) + content.addView(centeredLogo(64)) + content.addView(space(14)) + content.addView(title("Transaction history")) + content.addView( + subtitle( + "Confirmed received and sent VTX payments" + ) + ) + content.addView(space(20)) + + content.addView( + card { + addView(sectionTitle("Filter")) + addView(space(12)) + + val filterRow = LinearLayout( + this@WalletActivity + ).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + fun filterButton( + label: String, + filter: String + ): Button { + val selected = + transactionHistoryFilter == filter + + return Button( + this@WalletActivity + ).apply { + text = label + isAllCaps = false + textSize = 14f + typeface = Typeface.DEFAULT_BOLD + setTextColor(Color.WHITE) + setPadding( + dp(8), + dp(10), + dp(8), + dp(10) + ) + background = roundedDrawable( + if (selected) { + accentColor + } else { + surfaceColorAlt + }, + 14 + ) + setOnClickListener { + transactionHistoryFilter = filter + transactionHistoryLimit = 50 + showTransactionHistory() + } + layoutParams = + LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ).apply { + marginEnd = dp(5) + } + } + } + + filterRow.addView( + filterButton("All", "ALL") + ) + filterRow.addView( + filterButton("Received", "RECEIVED") + ) + filterRow.addView( + filterButton("Sent", "SENT") + ) + + addView(filterRow) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView( + sectionTitle( + when (transactionHistoryFilter) { + "RECEIVED" -> "Received payments" + "SENT" -> "Sent payments" + else -> "All transactions" + } + ) + ) + addView(space(10)) + + addView( + smallStatus( + formatTransactionHistory( + displayedTransactions, + limit = displayedTransactions.size, + showFullTxid = true + ) + ).apply { + gravity = Gravity.START + setTextIsSelectable(true) + } + ) + + if ( + filteredTransactions.size > + displayedTransactions.size + ) { + addView(space(16)) + addView( + secondaryButton("Load more") { + transactionHistoryLimit += 50 + showTransactionHistory() + } + ) + addView(space(8)) + addView( + helpText( + "Showing ${displayedTransactions.size} of " + + "${filteredTransactions.size}" + ) + ) + } else if (filteredTransactions.isNotEmpty()) { + addView(space(10)) + addView( + helpText( + "${filteredTransactions.size} transaction" + + if ( + filteredTransactions.size == 1 + ) { + "" + } else { + "s" + } + ) + ) + } + } + ) + + content.addView(space(20)) + content.addView( + primaryButton("Back to wallet") { + showMainWallet() + } + ) + + setContentView(scroll(content)) + } + + private fun currentVersionName(): String { + return try { + packageManager.getPackageInfo( + packageName, + 0 + ).versionName ?: "unknown" + } catch (_: Exception) { + "unknown" + } + } + + private fun showAboutDialog() { + val message = + "Vexta Wallet\n" + + "Version ${currentVersionName()}\n\n" + + "Developed for the Vexta Project\n" + + "© 2026 Vexta Project\n\n" + + "Website: vextaproject.org\n" + + "Support: discord.gg/zzpm7ghN3e\n\n" + + "Vexta Wallet is a non-custodial wallet. " + + "Your recovery phrase is stored locally and encrypted " + + "using Android Keystore. Keep your recovery phrase secure. " + + "Lost recovery phrases cannot be recovered.\n\n" + + "Open-source components:\n" + + "• bitcoinj — Apache License 2.0\n" + + "• ZXing — Apache License 2.0\n" + + "• Google ML Kit Code Scanner — Google API terms" + + AlertDialog.Builder(this) + .setTitle("About & Licences") + .setMessage(message) + .setPositiveButton("Close", null) + .show() + } + + private fun showWelcome() { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val content = baseLayout() + + content.addView(space(12)) + content.addView(centeredLogo(86)) + content.addView(space(18)) + content.addView(title("Vexta Wallet")) + content.addView( + subtitle( + "Lightweight Android wallet for Vexta mainnet\n" + + "SPV headers, compact filters and secure local seed storage" + ) + ) + content.addView(space(24)) + + content.addView( + card { + addView(sectionTitle("Get started")) + addView(space(10)) + addView(primaryButton("Create new wallet") { + createNewWallet() + }) + addView(space(12)) + addView(secondaryButton("Restore from 12 words") { + showRestoreDialog() + }) + } + ) + + setContentView(scroll(content)) + } + + private fun createNewWallet() { + val entropy = ByteArray(16) + SecureRandom().nextBytes(entropy) + + val words = MnemonicCode.INSTANCE.toMnemonic(entropy) + saveMnemonic(words.joinToString(" ")) + + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putBoolean(PREF_BACKUP_CONFIRMED, false) + .apply() + + showSeedBackup(words) + } + + private fun showSeedBackup(words: List) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val content = baseLayout() + content.addView(space(12)) + content.addView(centeredLogo(72)) + content.addView(space(16)) + content.addView(title("Recovery phrase")) + content.addView( + subtitle( + "Write these 12 words down in the correct order.\n" + + "Anyone with these words can control your VTX." + ) + ) + content.addView(space(22)) + + content.addView( + card { + addView(sectionTitle("Your 12 words")) + addView(space(14)) + + addView( + infoBox( + words.mapIndexed { index, word -> + "${index + 1}. $word" + }.joinToString("\n"), + 19f, + Gravity.START + ) + ) + } + ) + + content.addView(space(20)) + content.addView( + primaryButton("I have written it down") { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putBoolean(PREF_BACKUP_CONFIRMED, true) + .apply() + + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + showMainWallet() + } + ) + + setContentView(scroll(content)) + } + + private fun showMainWallet() { + transactionHistoryVisible = false + mainWalletVisible = true + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val mnemonic = try { + loadMnemonic() + } catch (e: Exception) { + showFatalError("Unable to decrypt wallet: ${e.message}") + return + } + + if (mnemonic.isBlank()) { + showWelcome() + return + } + + val words = mnemonic.split(" ") + val addressIndex = currentReceiveAddressIndex() + val address = deriveAddress(words, addressIndex) + + val content = baseLayout() + val balanceView = headlineValue("0.00000000 VTX") + walletBalanceView = balanceView + val compactFilterStatus = smallStatus("Compact filters: checking peers...") + val scanStatus = smallStatus("Wallet scan: not started") + val historyView = smallStatus("No confirmed transactions yet") + + blockchainScanStatus = scanStatus + walletHistoryView = historyView + + content.addView(space(4)) + content.addView(centeredLogo(76)) + content.addView(space(10)) + content.addView(title("Vexta Wallet")) + content.addView( + subtitle("Your gateway to the Vexta network") + ) + content.addView(space(20)) + + content.addView( + balanceCard { + addView( + TextView(this@WalletActivity).apply { + text = "TOTAL BALANCE" + textSize = 12f + letterSpacing = 0.18f + setTextColor(textSecondary) + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + } + ) + addView(space(12)) + addView(balanceView) + addView(space(8)) + addView( + TextView(this@WalletActivity).apply { + text = "VEXTA MAINNET" + textSize = 11f + letterSpacing = 0.12f + setTextColor(accentColor) + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + } + ) + addView(space(12)) + addView( + helpText( + "Verified through compact filters and relevant blocks" + ) + ) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Quick actions")) + addView(space(14)) + + val actionRow = LinearLayout( + this@WalletActivity + ).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + actionRow.addView( + compactActionButton( + symbol = "↑", + label = "Send" + ) { + showSend(address) + } + ) + + actionRow.addView( + compactActionButton( + symbol = "↓", + label = "Receive" + ) { + showReceive() + } + ) + + actionRow.addView( + compactActionButton( + symbol = "↻", + label = "Scan" + ) { + startHeaderSyncAndScan() + } + ) + + addView(actionRow) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Recent transactions")) + addView(space(10)) + addView(historyView) + addView(space(12)) + addView(secondaryButton("View all transactions") { + showTransactionHistory() + }) + addView(space(8)) + addView( + helpText( + "The three most recent confirmed transactions" + ) + ) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Status")) + addView(space(10)) + addView(compactFilterStatus) + addView(space(12)) + addView(scanStatus) + addView(space(12)) + addView(helpText("Wallet data is encrypted with Android Keystore")) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Security")) + addView(space(12)) + addView(secondaryButton("Show recovery phrase") { + confirmShowSeed() + }) + addView(space(10)) + addView(secondaryButton("About & Licences") { + showAboutDialog() + }) + addView(space(10)) + addView(dangerButton("Delete wallet") { + confirmDeleteWallet() + }) + } + ) + + content.addView(space(22)) + content.addView( + smallStatus( + "Vexta Wallet v${currentVersionName()}\n" + + "© 2026 Vexta Project\n" + + "Non-custodial wallet" + ).apply { + gravity = Gravity.CENTER + } + ) + content.addView(space(12)) + + setContentView(scroll(content)) + + refreshHandler.removeCallbacks(refreshRunnable) + refreshHandler.postDelayed(refreshRunnable, 60_000L) + + automaticScanStarted = true + + window.decorView.postDelayed({ + if ( + !isFinishing && + !isDestroyed && + mainWalletVisible && + !blockchainScanRunning + ) { + startHeaderSyncAndScan() + } + }, 500L) + + + Thread { + val peers = listOf("87.106.99.23", "74.208.53.160") + val results = peers.map { peer -> + try { + CompactFilterClient.check(peer) + } catch (_: Exception) { + null + } + } + + runOnUiThread { + val successful = results.filterNotNull() + val supported = successful.count { + it.servicesCompactFilters && it.filterHashesReceived > 0 + } + + compactFilterStatus.text = + "Compact filters: $supported/${peers.size} peers ready" + + if (supported == peers.size) { + "\nBIP157 connection test: successful" + } else { + "\nBIP157 connection test: incomplete" + } + } + }.start() + } + + private fun showReceive() { + mainWalletVisible = false + window.clearFlags( + WindowManager.LayoutParams.FLAG_SECURE + ) + + val words = try { + loadMnemonic() + .trim() + .split(Regex("\\s+")) + } catch (error: Exception) { + showFatalError( + "Unable to load wallet: " + + (error.message ?: error.javaClass.simpleName) + ) + return + } + + val addressIndex = currentReceiveAddressIndex() + val address = deriveAddress(words, addressIndex) + + val content = baseLayout() + content.addView(space(10)) + content.addView(centeredLogo(72)) + content.addView(space(14)) + content.addView(title("Receive VTX")) + content.addView( + subtitle( + "Share this address or QR code to receive coins" + ) + ) + content.addView(space(20)) + + content.addView( + card { + addView( + sectionTitle( + "Receive address ${addressIndex + 1}" + ) + ) + addView(space(14)) + + addView( + ImageView(this@WalletActivity).apply { + setImageBitmap( + createQrBitmap( + "vexta:$address", + 720 + ) + ) + adjustViewBounds = true + setPadding( + dp(16), + dp(16), + dp(16), + dp(16) + ) + background = + roundedDrawable(Color.WHITE, 24) + layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + ) + + addView(space(16)) + + val addressView = infoBox( + address, + 16f, + Gravity.CENTER + ).apply { + isClickable = true + isFocusable = true + + setOnClickListener { + val clipboard = getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager + + clipboard.setPrimaryClip( + ClipData.newPlainText( + "Vexta address", + address + ) + ) + + Toast.makeText( + this@WalletActivity, + "Address copied", + Toast.LENGTH_SHORT + ).show() + } + } + + addView(addressView) + addView(space(10)) + addView( + helpText( + "Tap the address to copy it" + ) + ) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Address management")) + addView(space(12)) + addView( + secondaryButton("Generate new address") { + AlertDialog.Builder( + this@WalletActivity + ) + .setTitle("Generate new address?") + .setMessage( + "The current address will remain " + + "valid and the wallet will continue " + + "scanning it. A new address will be " + + "generated from the same recovery phrase." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Generate") { + _, _ -> + setCurrentReceiveAddressIndex( + addressIndex + 1 + ) + showReceive() + } + .show() + } + ) + addView(space(10)) + addView( + helpText( + "All previously generated addresses remain " + + "part of this wallet." + ) + ) + } + ) + + content.addView(space(20)) + content.addView( + primaryButton("Back to wallet") { + showMainWallet() + } + ) + + setContentView(scroll(content)) + } + + private fun waitForTransactionAcceptance( + txid: String, + attempts: Int = 20, + delayMilliseconds: Long = 1_000L + ): Boolean { + repeat(attempts) { attempt -> + var connection: java.net.HttpURLConnection? = null + + try { + val encodedTxid = + java.net.URLEncoder.encode(txid, "UTF-8") + + connection = + java.net.URL( + "https://vextaproject.org/explorer/api/tx/$encodedTxid" + ).openConnection() as java.net.HttpURLConnection + + connection.requestMethod = "GET" + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + connection.useCaches = false + + if (connection.responseCode == 200) { + return true + } + } catch (_: Exception) { + } finally { + connection?.disconnect() + } + + if (attempt + 1 < attempts) { + Thread.sleep(delayMilliseconds) + } + } + + return false + } + + private fun showSend(fromAddress: String) { + mainWalletVisible = false + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val recipientInput = inputField( + hint = "Recipient address (vtx1... or legacy V...)", + singleLine = false + ) + + sendRecipientInput = recipientInput + + val amountInput = inputField( + hint = "Amount in VTX", + singleLine = true, + inputType = InputType.TYPE_CLASS_NUMBER or + InputType.TYPE_NUMBER_FLAG_DECIMAL + ) + + val noteView = smallStatus( + "Payments are signed locally on this device and broadcast " + + "directly to the Vexta network." + ) + + val content = baseLayout() + content.addView(space(10)) + content.addView(centeredLogo(72)) + content.addView(space(14)) + content.addView(title("Send VTX")) + content.addView(subtitle("Create and broadcast a Vexta payment")) + content.addView(space(20)) + + content.addView( + card { + addView(sectionTitle("From")) + addView(space(10)) + addView(infoBox(fromAddress, 15f, Gravity.CENTER)) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Payment details")) + addView(space(12)) + addView(recipientInput) + addView(space(10)) + + addView(secondaryButton("Scan QR code") { + val options = GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .enableAutoZoom() + .build() + + GmsBarcodeScanning + .getClient(this@WalletActivity, options) + .startScan() + .addOnSuccessListener { barcode -> + insertScannedAddress(barcode.rawValue) + } + .addOnCanceledListener { + Toast.makeText( + this@WalletActivity, + "QR scan cancelled", + Toast.LENGTH_SHORT + ).show() + } + .addOnFailureListener { error -> + Toast.makeText( + this@WalletActivity, + "QR scanner failed: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + }) + + addView(space(12)) + addView(amountInput) + addView(space(14)) + addView(primaryButton("Review payment") { + if (sendInProgress) { + Toast.makeText( + this@WalletActivity, + "A transaction is already being sent", + Toast.LENGTH_LONG + ).show() + return@primaryButton + } + + val recipient = + recipientInput.text.toString().trim() + val amountText = + amountInput.text.toString().trim() + + if (recipient.isBlank()) { + Toast.makeText( + this@WalletActivity, + "Enter a Vexta recipient address", + Toast.LENGTH_LONG + ).show() + return@primaryButton + } + + val amountSatoshis = try { + BigDecimal(amountText) + .setScale(8, RoundingMode.UNNECESSARY) + .movePointRight(8) + .longValueExact() + } catch (_: Exception) { + Toast.makeText( + this@WalletActivity, + "Enter a valid amount with no more than 8 decimals", + Toast.LENGTH_LONG + ).show() + return@primaryButton + } + + if (amountSatoshis <= 0L) { + Toast.makeText( + this@WalletActivity, + "Amount must be greater than zero", + Toast.LENGTH_LONG + ).show() + return@primaryButton + } + + if (latestSpendableUtxos.isEmpty()) { + Toast.makeText( + this@WalletActivity, + "No verified spendable outputs are loaded. " + + "Return to the wallet and scan the blockchain.", + Toast.LENGTH_LONG + ).show() + return@primaryButton + } + + try { + val words = loadMnemonic() + .trim() + .split(Regex("\\s+")) + + val privateKeysByIndex = + latestSpendableUtxos + .map { it.addressIndex } + .distinct() + .associateWith { index -> + deriveWalletPrivateKey( + words, + index + ) + } + + val changePrivateKey = + deriveWalletPrivateKey( + words, + currentReceiveAddressIndex() + ) + + val changePubKeyHash = + Utils.sha256hash160( + changePrivateKey.pubKey + ) + + val transaction = + VextaTransactionSender.createAndSign( + spendableUtxos = + latestSpendableUtxos, + recipientAddress = recipient, + amountSatoshis = amountSatoshis, + privateKeysByIndex = + privateKeysByIndex, + changePubKeyHash = + changePubKeyHash + ) + + val amountDisplay = String.format( + "%.8f", + transaction.amount.toDouble() / + 100_000_000.0 + ) + + val feeDisplay = String.format( + "%.8f", + transaction.fee.toDouble() / + 100_000_000.0 + ) + + val changeDisplay = String.format( + "%.8f", + transaction.change.toDouble() / + 100_000_000.0 + ) + + AlertDialog.Builder(this@WalletActivity) + .setTitle("Confirm VTX payment") + .setMessage( + "Recipient:\n$recipient\n\n" + + "Amount: $amountDisplay VTX\n" + + "Network fee: $feeDisplay VTX\n" + + "Change: $changeDisplay VTX\n" + + "Inputs: ${transaction.inputCount}\n\n" + + "Check the recipient address carefully. " + + "Blockchain transactions cannot be reversed." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Send") { _, _ -> + sendInProgress = true + noteView.text = + "Broadcasting transaction..." + + Thread { + try { + if ( + waitForTransactionAcceptance( + transaction.txid, + attempts = 1, + delayMilliseconds = 0L + ) + ) { + throw IllegalStateException( + "This transaction already exists. " + + "Wait for confirmation and refresh " + + "the wallet before sending again." + ) + } + + val peers = + VextaTransactionSender.broadcast( + transaction, + listOf( + "87.106.99.23", + "74.208.53.160" + ) + ) + + val accepted = + waitForTransactionAcceptance( + transaction.txid + ) + + runOnUiThread { + sendInProgress = false + latestSpendableUtxos = + latestSpendableUtxos.filterNot { + "${it.txid}:${it.outputIndex}" in + transaction.spentOutpoints + } + + noteView.text = + if (accepted) { + "Transaction accepted by the " + + "Vexta network.\nTXID:\n" + + transaction.txid + } else { + "Transaction submitted to " + + "$peers peer(s), but network " + + "acceptance could not yet be " + + "confirmed.\nTXID:\n" + + transaction.txid + } + + recipientInput.text.clear() + amountInput.text.clear() + + val rawTransactionHex = + transaction.rawTransaction + .joinToString("") { + "%02x".format( + it.toInt() and 0xff + ) + } + + AlertDialog.Builder( + this@WalletActivity + ) + .setTitle( + if (accepted) { + "Transaction accepted" + } else { + "Transaction broadcast" + } + ) + .setMessage( + "TXID:\n" + + transaction.txid + + if (accepted) { + "\n\nThe transaction was " + + "accepted by the Vexta " + + "network. Its inputs are now " + + "locked in this wallet until " + + "the next blockchain scan." + } else { + "\n\nThe transaction was sent " + + "to peers, but network " + + "acceptance could not yet " + + "be confirmed." + } + ) + .setNeutralButton( + "Copy raw TX" + ) { _, _ -> + val clipboard = + getSystemService( + Context + .CLIPBOARD_SERVICE + ) as ClipboardManager + + clipboard.setPrimaryClip( + ClipData.newPlainText( + "Vexta raw transaction", + rawTransactionHex + ) + ) + + Toast.makeText( + this@WalletActivity, + "Raw transaction copied", + Toast.LENGTH_LONG + ).show() + } + .setPositiveButton( + "OK", + null + ) + .show() + } + } catch (error: Exception) { + runOnUiThread { + sendInProgress = false + noteView.text = + "Transaction broadcast failed:\n" + + ( + error.message + ?: error.javaClass + .simpleName + ) + } + } + }.start() + } + .show() + } catch (error: Exception) { + Toast.makeText( + this@WalletActivity, + "Payment could not be prepared: " + + ( + error.message + ?: error.javaClass.simpleName + ), + Toast.LENGTH_LONG + ).show() + } + }) + } + ) + + content.addView(space(16)) + + content.addView( + card { + addView(sectionTitle("Status")) + addView(space(10)) + addView(noteView) + } + ) + + content.addView(space(20)) + content.addView(secondaryButton("Back to wallet") { + showMainWallet() + }) + + setContentView(scroll(content)) + } + + private fun insertScannedAddress(rawValue: String?) { + if (rawValue.isNullOrBlank()) { + Toast.makeText( + this, + "QR code is empty", + Toast.LENGTH_LONG + ).show() + return + } + + val value = rawValue.trim() + val extractedAddress = when { + value.startsWith("vexta:", true) -> { + value.substringAfter(":") + .substringBefore("?") + .trim() + } + else -> value + } + + val address = if ( + extractedAddress.startsWith("vtx1", true) + ) { + extractedAddress.lowercase() + } else { + extractedAddress + } + + if ( + address.startsWith("vtx1") || + address.startsWith("V") + ) { + sendRecipientInput?.setText(address) + + Toast.makeText( + this, + "Address inserted", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + this, + "QR code does not contain a Vexta address", + Toast.LENGTH_LONG + ).show() + } + } + + private fun showRestoreDialog() { + val input = EditText(this).apply { + hint = "Enter the 12 recovery words" + minLines = 5 + gravity = Gravity.TOP + setTextColor(textPrimary) + setHintTextColor(textSecondary) + setPadding(dp(16), dp(16), dp(16), dp(16)) + background = roundedDrawable(surfaceColor, 18) + inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + + AlertDialog.Builder(this) + .setTitle("Restore Vexta Wallet") + .setMessage("Enter all 12 words separated by spaces.") + .setView(input) + .setNegativeButton("Cancel", null) + .setPositiveButton("Restore") { _, _ -> + restoreWallet(input.text.toString()) + } + .show() + } + + private fun restoreWallet(input: String) { + try { + val words = input + .trim() + .lowercase() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + + if (words.size != 12) { + throw IllegalArgumentException("Exactly 12 words are required") + } + + MnemonicCode.INSTANCE.check(words) + saveMnemonic(words.joinToString(" ")) + + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putBoolean(PREF_BACKUP_CONFIRMED, true) + .putInt(PREF_RECEIVE_ADDRESS_INDEX, 0) + .putBoolean( + PREF_RESTORE_ADDRESS_DISCOVERY, + true + ) + .apply() + + Toast.makeText(this, "Wallet restored", Toast.LENGTH_LONG).show() + showMainWallet() + } catch (e: Exception) { + Toast.makeText( + this, + "Invalid recovery phrase: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + + private fun confirmShowSeed() { + AlertDialog.Builder(this) + .setTitle("Security warning") + .setMessage( + "Never show your recovery phrase to anyone. " + + "Make sure nobody can see your screen." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Show") { _, _ -> + val words = loadMnemonic().split(" ") + showSeedBackup(words) + } + .show() + } + + private fun confirmDeleteWallet() { + AlertDialog.Builder(this) + .setTitle("Delete wallet?") + .setMessage( + "This removes the encrypted seed from this phone. " + + "The wallet can only be recovered with its 12 words." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Delete") { _, _ -> + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .clear() + .apply() + + showWelcome() + } + .show() + } + + private fun walletExists(): Boolean { + return getSharedPreferences(PREFS, MODE_PRIVATE) + .contains(PREF_CIPHERTEXT) + } + + private fun saveMnemonic(mnemonic: String) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + + val ciphertext = cipher.doFinal(mnemonic.toByteArray(Charsets.UTF_8)) + val prefs = getSharedPreferences(PREFS, MODE_PRIVATE) + + prefs.edit() + .putString( + PREF_CIPHERTEXT, + android.util.Base64.encodeToString( + ciphertext, + android.util.Base64.NO_WRAP + ) + ) + .putString( + PREF_IV, + android.util.Base64.encodeToString( + cipher.iv, + android.util.Base64.NO_WRAP + ) + ) + .apply() + } + + private fun loadMnemonic(): String { + val prefs = getSharedPreferences(PREFS, MODE_PRIVATE) + + val ciphertext = android.util.Base64.decode( + prefs.getString(PREF_CIPHERTEXT, "") ?: "", + android.util.Base64.NO_WRAP + ) + + val iv = android.util.Base64.decode( + prefs.getString(PREF_IV, "") ?: "", + android.util.Base64.NO_WRAP + ) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + getOrCreateSecretKey(), + GCMParameterSpec(128, iv) + ) + + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + val existing = keyStore.getKey(KEY_ALIAS, null) + + if (existing is SecretKey) { + return existing + } + + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + + val specification = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + + generator.init(specification) + return generator.generateKey() + } + + private fun deriveWalletPrivateKey( + words: List, + addressIndex: Int + ): ECKey { + MnemonicCode.INSTANCE.check(words) + + val seedBytes = MnemonicCode.toSeed(words, "") + val masterKey = + HDKeyDerivation.createMasterPrivateKey(seedBytes) + val hierarchy = DeterministicHierarchy(masterKey) + + val path = listOf( + ChildNumber(84, true), + ChildNumber(0, true), + ChildNumber(0, true), + ChildNumber.ZERO, + ChildNumber(addressIndex, false) + ) + + val deterministicKey = + hierarchy.get(path, true, true) + + return ECKey.fromPrivate( + deterministicKey.privKeyBytes, + true + ) + } + + private fun deriveWitnessScript( + words: List, + addressIndex: Int + ): ByteArray { + MnemonicCode.INSTANCE.check(words) + + val seedBytes = MnemonicCode.toSeed(words, "") + val masterKey = HDKeyDerivation.createMasterPrivateKey(seedBytes) + val hierarchy = DeterministicHierarchy(masterKey) + + val path = listOf( + ChildNumber(84, true), + ChildNumber(0, true), + ChildNumber(0, true), + ChildNumber.ZERO, + ChildNumber(addressIndex, false) + ) + + val deterministicKey = hierarchy.get(path, true, true) + val ecKey = ECKey.fromPrivate( + deterministicKey.privKeyBytes, + true + ) + + val pubKeyHash = Utils.sha256hash160(ecKey.pubKey) + return byteArrayOf(0x00, 0x14) + pubKeyHash + } + + private fun deriveAddress( + words: List, + addressIndex: Int + ): String { + MnemonicCode.INSTANCE.check(words) + + val seedBytes = MnemonicCode.toSeed(words, "") + val masterKey = HDKeyDerivation.createMasterPrivateKey(seedBytes) + val hierarchy = DeterministicHierarchy(masterKey) + + val path = listOf( + ChildNumber(84, true), + ChildNumber(0, true), + ChildNumber(0, true), + ChildNumber.ZERO, + ChildNumber(addressIndex, false) + ) + + val deterministicKey = hierarchy.get(path, true, true) + val ecKey = ECKey.fromPrivate( + deterministicKey.privKeyBytes, + true + ) + + val pubKeyHash = Utils.sha256hash160(ecKey.pubKey) + return encodeSegwitAddress("vtx", 0, pubKeyHash) + } + + private fun encodeSegwitAddress( + hrp: String, + witnessVersion: Int, + witnessProgram: ByteArray + ): String { + val data = mutableListOf() + data.add(witnessVersion) + data.addAll(convertBits(witnessProgram, 8, 5, true)) + + val checksum = createBech32Checksum(hrp, data) + val charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + return hrp + "1" + + (data + checksum).joinToString("") { charset[it].toString() } + } + + private fun convertBits( + input: ByteArray, + fromBits: Int, + toBits: Int, + pad: Boolean + ): List { + var accumulator = 0 + var bits = 0 + val result = mutableListOf() + val maxValue = (1 shl toBits) - 1 + val maxAccumulator = (1 shl (fromBits + toBits - 1)) - 1 + + for (byte in input) { + val value = byte.toInt() and 0xff + accumulator = ((accumulator shl fromBits) or value) and maxAccumulator + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result.add((accumulator shr bits) and maxValue) + } + } + + if (pad && bits > 0) { + result.add((accumulator shl (toBits - bits)) and maxValue) + } + + return result + } + + private fun createBech32Checksum( + hrp: String, + data: List + ): List { + val values = expandBech32Hrp(hrp) + data + List(6) { 0 } + val polymod = bech32Polymod(values) xor 1 + + return (0 until 6).map { index -> + (polymod shr (5 * (5 - index))) and 31 + } + } + + private fun expandBech32Hrp(hrp: String): List { + return hrp.map { it.code shr 5 } + + listOf(0) + + hrp.map { it.code and 31 } + } + + private fun bech32Polymod(values: List): Int { + val generators = intArrayOf( + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3 + ) + + var checksum = 1 + + for (value in values) { + val top = checksum ushr 25 + checksum = ((checksum and 0x1ffffff) shl 5) xor value + + for (index in 0..4) { + if (((top shr index) and 1) != 0) { + checksum = checksum xor generators[index] + } + } + } + + return checksum + } + + private fun createQrBitmap(value: String, size: Int): Bitmap { + val matrix = MultiFormatWriter().encode( + value, + BarcodeFormat.QR_CODE, + size, + size + ) + + val bitmap = Bitmap.createBitmap( + size, + size, + Bitmap.Config.RGB_565 + ) + + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel( + x, + y, + if (matrix[x, y]) Color.BLACK else Color.WHITE + ) + } + } + + return bitmap + } + + private fun baseLayout(): LinearLayout { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dp(18), dp(52), dp(18), dp(28)) + setBackgroundColor(backgroundColor) + } + } + + private fun scroll(content: View): ScrollView { + return ScrollView(this).apply { + setBackgroundColor(backgroundColor) + addView(content) + } + } + + private fun centeredLogo(sizeDp: Int): ImageView { + return ImageView(this).apply { + setImageResource(R.drawable.vexta_logo) + adjustViewBounds = true + scaleType = ImageView.ScaleType.FIT_CENTER + layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)).apply { + gravity = Gravity.CENTER_HORIZONTAL + } + } + } + + private fun card(build: LinearLayout.() -> Unit): LinearLayout { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(18), dp(18), dp(18), dp(18)) + background = glassDrawable( + surfaceColor, + 24, + adjustAlpha(accentColor, 0.28f) + ) + elevation = dp(2).toFloat() + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + build() + } + } + + private fun balanceCard( + build: LinearLayout.() -> Unit + ): LinearLayout { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(22), dp(26), dp(22), dp(24)) + background = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf( + Color.rgb(12, 47, 87), + Color.rgb(10, 27, 60), + Color.rgb(35, 20, 82) + ) + ).apply { + cornerRadius = dp(28).toFloat() + setStroke( + dp(1), + adjustAlpha(accentColor, 0.55f) + ) + } + elevation = dp(5).toFloat() + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + build() + } + } + + private fun compactActionButton( + symbol: String, + label: String, + action: () -> Unit + ): LinearLayout { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(8), dp(12), dp(8), dp(12)) + background = glassDrawable( + surfaceColorAlt, + 18, + adjustAlpha(accentColor, 0.35f) + ) + isClickable = true + isFocusable = true + setOnClickListener { action() } + + addView( + TextView(this@WalletActivity).apply { + text = symbol + textSize = 27f + setTextColor(accentColor) + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + } + ) + + addView(space(5)) + + addView( + TextView(this@WalletActivity).apply { + text = label + textSize = 13f + setTextColor(textPrimary) + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + } + ) + + layoutParams = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ).apply { + marginStart = dp(4) + marginEnd = dp(4) + } + } + } + + private fun title(value: String): TextView { + return TextView(this).apply { + text = value + textSize = 30f + letterSpacing = 0.02f + setTextColor(textPrimary) + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + } + } + + private fun subtitle(value: String): TextView { + return TextView(this).apply { + text = value + textSize = 15f + setTextColor(textSecondary) + gravity = Gravity.CENTER + } + } + + private fun sectionTitle(value: String): TextView { + return TextView(this).apply { + text = value.uppercase() + textSize = 13f + letterSpacing = 0.12f + setTextColor(accentColor) + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.START + } + } + + private fun headlineValue(value: String): TextView { + return TextView(this).apply { + text = value + textSize = 36f + letterSpacing = 0.015f + setTextColor(textPrimary) + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + } + } + + private fun helpText(value: String): TextView { + return TextView(this).apply { + text = value + textSize = 13f + setTextColor(textSecondary) + gravity = Gravity.CENTER + } + } + + private fun smallStatus(value: String): TextView { + return TextView(this).apply { + text = value + textSize = 15f + setTextColor(textPrimary) + gravity = Gravity.CENTER + } + } + + private fun infoBox( + value: String, + textSizeValue: Float, + gravityValue: Int + ): TextView { + return TextView(this).apply { + text = value + textSize = textSizeValue + setTextColor(textPrimary) + gravity = gravityValue + setPadding(dp(16), dp(16), dp(16), dp(16)) + background = roundedDrawable(surfaceColorAlt, 18) + setTextIsSelectable(true) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + } + + private fun inputField( + hint: String, + singleLine: Boolean, + inputType: Int = InputType.TYPE_CLASS_TEXT + ): EditText { + return EditText(this).apply { + this.hint = hint + this.inputType = inputType + this.isSingleLine = singleLine + setTextColor(textPrimary) + setHintTextColor(textSecondary) + setPadding(dp(16), dp(16), dp(16), dp(16)) + background = roundedDrawable(surfaceColorAlt, 18) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + } + + private fun primaryButton( + label: String, + action: () -> Unit + ): Button { + return styledButton( + label = label, + background = accentColor, + textColor = Color.WHITE, + action = action + ) + } + + private fun secondaryButton( + label: String, + action: () -> Unit + ): Button { + return styledButton( + label = label, + background = surfaceColorAlt, + textColor = Color.WHITE, + action = action + ) + } + + private fun dangerButton( + label: String, + action: () -> Unit + ): Button { + return styledButton( + label = label, + background = dangerColor, + textColor = Color.WHITE, + action = action + ) + } + + private fun styledButton( + label: String, + background: Int, + textColor: Int, + action: () -> Unit + ): Button { + return Button(this).apply { + text = label + isAllCaps = false + textSize = 16f + letterSpacing = 0.04f + typeface = Typeface.DEFAULT_BOLD + setTextColor(textColor) + setPadding(dp(16), dp(15), dp(16), dp(15)) + this.background = glassDrawable( + background, + 20, + adjustAlpha(Color.WHITE, 0.18f) + ) + elevation = dp(2).toFloat() + setOnClickListener { action() } + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + } + + private fun glassDrawable( + color: Int, + radiusDp: Int, + strokeColor: Int + ): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(radiusDp).toFloat() + setColor(color) + setStroke(dp(1), strokeColor) + } + } + + private fun roundedDrawable( + color: Int, + radiusDp: Int + ): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(radiusDp).toFloat() + setColor(color) + setStroke(dp(1), adjustAlpha(Color.WHITE, 0.08f)) + } + } + + private fun space(heightDp: Int): View { + return View(this).apply { + layoutParams = LinearLayout.LayoutParams( + 1, + dp(heightDp) + ) + } + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private fun adjustAlpha(color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).toInt() + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } + + private fun showFatalError(message: String) { + val content = baseLayout() + content.addView(space(20)) + content.addView(centeredLogo(72)) + content.addView(space(14)) + content.addView(title("Vexta Wallet")) + content.addView(space(20)) + content.addView( + card { + addView(sectionTitle("Error")) + addView(space(12)) + addView( + TextView(this@WalletActivity).apply { + text = message + textSize = 16f + setTextColor(Color.RED) + gravity = Gravity.CENTER + } + ) + addView(space(14)) + addView(dangerButton("Delete wallet data") { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .clear() + .apply() + + showWelcome() + }) + } + ) + setContentView(scroll(content)) + } +} diff --git a/app/src/main/res/drawable-nodpi/vexta_logo.png b/app/src/main/res/drawable-nodpi/vexta_logo.png new file mode 100644 index 0000000..1f7f804 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/vexta_logo.png differ diff --git a/app/src/main/res/drawable/vexta_icon_foreground.png b/app/src/main/res/drawable/vexta_icon_foreground.png new file mode 100644 index 0000000..c69b070 Binary files /dev/null and b/app/src/main/res/drawable/vexta_icon_foreground.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bb89852 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bb89852 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..04754fc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..04754fc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..3ebbd38 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..3ebbd38 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..bb80ea8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bb80ea8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e382360 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e382360 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..073325d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..073325d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/icon_colors.xml b/app/src/main/res/values/icon_colors.xml new file mode 100644 index 0000000..a522c20 --- /dev/null +++ b/app/src/main/res/values/icon_colors.xml @@ -0,0 +1,4 @@ + + + #051428 + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..a1fb1b5 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..cf37549 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.3.0" apply false +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7af231e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "VextaWallet" +include(":app")