Initial release of Vexta Wallet for Android

This commit is contained in:
Vexta Project 2026-08-02 23:17:40 +00:00
commit b5e3128628
34 changed files with 6735 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.gradle/
.kotlin/
local.properties
**/build/
*.iml
.idea/
*.jks
*.keystore
keystore.properties
app/build.gradle.kts.backup-*

11
PRIVACY.md Normal file
View file

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

43
README.md Normal file
View file

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

7
SECURITY.md Normal file
View file

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

63
app/build.gradle.kts Normal file
View file

@ -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"
)
}

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:label="Vexta Wallet"
android:theme="@style/AppTheme"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<activity
android:name=".WalletActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/SyncActivityTheme"
android:noHistory="true"
android:excludeFromRecents="true" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
</application>
</manifest>

View file

@ -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<SpendableUtxo>,
val transactions: List<WalletTransaction>
)
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<OutPoint>,
val outputs: List<TransactionOutput>
)
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<Int>,
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<String, Utxo>()
var receivedTransactions = 0
var spentTransactions = 0
var downloadedBlocks = 0
val walletTransactions = mutableListOf<WalletTransaction>()
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<Utxo> { 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<WalletTransaction> {
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<ParsedTransaction> {
val cursor = Cursor(payload)
cursor.skip(80)
val transactionCount = cursor.readCompactSize()
if (transactionCount > 1_000_000) {
throw IllegalStateException("Invalid transaction count")
}
val transactions = ArrayList<ParsedTransaction>(
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<OutPoint>(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<TransactionOutput>(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>
): 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<ByteArray> {
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<String, ByteArray> {
val header = ByteArray(24)
input.readFully(header)
if (!header.copyOfRange(0, 4).contentEquals(magic)) {
throw IllegalStateException("Invalid Vexta network magic")
}
val command = header.copyOfRange(4, 16)
.takeWhile { it.toInt() != 0 }
.toByteArray()
.toString(Charsets.US_ASCII)
val payloadLength = ByteBuffer
.wrap(header, 16, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.int
if (payloadLength < 0 || payloadLength > 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
}
}
}
}
}

View file

@ -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<Int>,
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<Int>()
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<ByteArray> {
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<String, ByteArray> {
val header = ByteArray(24)
input.readFully(header)
if (!header.copyOfRange(0, 4).contentEquals(magic)) {
throw IllegalStateException("invalid Vexta network magic")
}
val command = header.copyOfRange(4, 16)
.takeWhile { it.toInt() != 0 }
.toByteArray()
.toString(Charsets.US_ASCII)
val payloadLength = ByteBuffer.wrap(header, 16, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.int
if (payloadLength < 0 || payloadLength > 4_000_000) {
throw IllegalStateException("invalid payload length")
}
val expectedChecksum = header.copyOfRange(20, 24)
val payload = ByteArray(payloadLength)
input.readFully(payload)
val actualChecksum = doubleSha256(payload).copyOfRange(0, 4)
if (!expectedChecksum.contentEquals(actualChecksum)) {
throw IllegalStateException("invalid message checksum")
}
return command to payload
}
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))
}
}

View file

@ -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<Long, Int> {
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
}
}
}

View file

@ -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<String>()
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<ChainHeader> {
val chain = mutableListOf(
ChainHeader(
height = 0,
raw = null,
hashWire = hashHexToWireBytes(GENESIS_HASH),
hashDisplay = GENESIS_HASH,
time = GENESIS_TIME,
bits = GENESIS_BITS
)
)
val file = File(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<ChainHeader>) {
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<ChainHeader>
): SyncResult {
Socket().use { socket ->
socket.soTimeout = 15000
socket.connect(InetSocketAddress(host, PORT), 5000)
val input = DataInputStream(socket.getInputStream())
val output = socket.getOutputStream()
sendMessage(output, "version", createVersionPayload(host))
var versionInfo: VersionInfo? = null
var gotVerack = false
var requested = false
val cachedBefore = chain.last().height
repeat(200) {
val message = readMessage(input)
when (message.first) {
"version" -> {
versionInfo = parseVersionPayload(message.second)
sendMessage(output, "verack", byteArrayOf())
}
"verack" -> gotVerack = true
"ping" -> sendMessage(output, "pong", message.second)
"headers" -> {
val received = parseHeaders(message.second, chain)
val remote = versionInfo
?: throw IllegalStateException("missing node version")
if (chain.last().height > remote.height) {
throw IllegalStateException(
"local verified chain exceeds peer height"
)
}
return SyncResult(
received = received,
cachedBefore = cachedBefore,
chainHeight = chain.last().height,
lastHash = chain.last().hashDisplay
)
}
}
if (versionInfo != null && gotVerack && !requested) {
sendMessage(
output,
"getheaders",
createGetHeadersPayload(chain.last().hashWire)
)
requested = true
}
}
throw IllegalStateException("headers response timeout")
}
}
private fun parseHeaders(
payload: ByteArray,
chain: MutableList<ChainHeader>
): Int {
val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
val count = readCompactSize(buffer)
if (count > 2000) {
throw IllegalStateException("too many headers: $count")
}
repeat(count) {
if (buffer.remaining() < 81) {
throw IllegalStateException("truncated headers message")
}
val raw = ByteArray(80)
buffer.get(raw)
val transactionCount = readCompactSize(buffer)
if (transactionCount != 0) {
throw IllegalStateException(
"headers message contains transactions"
)
}
appendVerifiedHeader(chain, raw)
}
return count
}
private fun appendVerifiedHeader(
chain: MutableList<ChainHeader>,
raw: ByteArray
) {
val previous = chain.last()
val previousHash = raw.copyOfRange(4, 36)
if (!previousHash.contentEquals(previous.hashWire)) {
throw IllegalStateException(
"header linkage failed at height ${previous.height + 1}"
)
}
val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
val time = buffer.getInt(68).toLong() and 0xffffffffL
val bits = buffer.getInt(72).toLong() and 0xffffffffL
val target = compactToTarget(bits)
if (target <= BigInteger.ZERO || target > POW_LIMIT) {
throw IllegalStateException(
"invalid target at height ${previous.height + 1}"
)
}
val hashWire = doubleSha256(raw)
val hashNumber = BigInteger(1, hashWire.reversedArray())
if (hashNumber > target) {
throw IllegalStateException(
"proof of work failed at height ${previous.height + 1}"
)
}
val expectedBits = expectedBits(chain)
if (bits != expectedBits) {
throw IllegalStateException(
"difficulty mismatch at height ${previous.height + 1}: " +
"expected ${expectedBits.toString(16)}, " +
"received ${bits.toString(16)}"
)
}
chain.add(
ChainHeader(
height = previous.height + 1,
raw = raw,
hashWire = hashWire,
hashDisplay = hashWire.reversedArray().toHex(),
time = time,
bits = bits
)
)
}
private fun expectedBits(chain: List<ChainHeader>): Long {
val last = chain.last()
if (last.height < AVERAGING_WINDOW) {
return targetToCompact(POW_LIMIT)
}
val first = chain[last.height - AVERAGING_WINDOW]
var actualTimespan =
medianTimePast(chain, last.height) -
medianTimePast(chain, first.height)
actualTimespan =
TARGET_TIMESPAN +
(actualTimespan - TARGET_TIMESPAN) / 4
actualTimespan = actualTimespan.coerceIn(
MIN_TIMESPAN,
MAX_TIMESPAN
)
var newTarget =
compactToTarget(last.bits)
.multiply(BigInteger.valueOf(actualTimespan))
.divide(BigInteger.valueOf(TARGET_TIMESPAN))
if (newTarget > POW_LIMIT) {
newTarget = POW_LIMIT
}
return targetToCompact(newTarget)
}
private fun medianTimePast(
chain: List<ChainHeader>,
height: Int
): Long {
val start = maxOf(0, height - 10)
val times = (start..height)
.map { chain[it].time }
.sorted()
return times[times.size / 2]
}
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<String, ByteArray> {
val header = ByteArray(24)
input.readFully(header)
if (!header.copyOfRange(0, 4).contentEquals(MAGIC)) {
throw IllegalStateException("invalid Vexta network magic")
}
val command = header.copyOfRange(4, 16)
.takeWhile { it.toInt() != 0 }
.toByteArray()
.toString(Charsets.US_ASCII)
val payloadLength = ByteBuffer.wrap(header, 16, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.int
if (payloadLength < 0 || payloadLength > 4_000_000) {
throw IllegalStateException("invalid payload length")
}
val expectedChecksum = header.copyOfRange(20, 24)
val payload = ByteArray(payloadLength)
input.readFully(payload)
val actualChecksum = doubleSha256(payload).copyOfRange(0, 4)
if (!expectedChecksum.contentEquals(actualChecksum)) {
throw IllegalStateException("invalid message checksum")
}
return command to payload
}
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))
}
}

View file

@ -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<String>
)
fun createAndSign(
spendableUtxos: List<BlockScanner.SpendableUtxo>,
recipientAddress: String,
amountSatoshis: Long,
privateKeysByIndex: Map<Int, ECKey>,
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<BlockScanner.SpendableUtxo> { it.height }
.thenBy { it.txid }
.thenBy { it.outputIndex }
)
val selected =
mutableListOf<BlockScanner.SpendableUtxo>()
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<String>
): 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<BlockScanner.SpendableUtxo>,
outputs: List<TransactionOutputData>,
signatures: List<ByteArray>,
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<TransactionOutputData>
): 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<Int>,
fromBits: Int,
toBits: Int,
pad: Boolean
): List<Int> {
var accumulator = 0
var bits = 0
val result = mutableListOf<Int>()
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<Int> =
hrp.map { it.code shr 5 } +
listOf(0) +
hrp.map { it.code and 31 }
private fun bech32Polymod(values: List<Int>): 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<String, ByteArray> {
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
)
}

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/vexta_icon_background" />
<foreground android:drawable="@drawable/vexta_icon_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/vexta_icon_background" />
<foreground android:drawable="@drawable/vexta_icon_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="vexta_icon_background">#051428</color>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">#00D2FF</item>
<item name="android:windowBackground">#020817</item>
<item name="android:statusBarColor">#020817</item>
<item name="android:navigationBarColor">#020817</item>
</style>
<style name="SyncActivityTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowDisablePreview">true</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>

3
build.gradle.kts Normal file
View file

@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.3.0" apply false
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

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

248
gradlew vendored Executable file
View file

@ -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" "$@"

82
gradlew.bat vendored Normal file
View file

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

18
settings.gradle.kts Normal file
View file

@ -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")