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

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