Initial release of Vexta Wallet for Android
38
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
746
app/src/main/java/org/vextaproject/wallet/BlockScanner.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
557
app/src/main/java/org/vextaproject/wallet/CompactFilterClient.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
312
app/src/main/java/org/vextaproject/wallet/GcsFilter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
719
app/src/main/java/org/vextaproject/wallet/MainActivity.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
2911
app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
Normal file
BIN
app/src/main/res/drawable-nodpi/vexta_logo.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
app/src/main/res/drawable/vexta_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
4
app/src/main/res/values/icon_colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="vexta_icon_background">#051428</color>
|
||||
</resources>
|
||||
22
app/src/main/res/values/styles.xml
Normal 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>
|
||||