Initial release of Vexta Wallet for Android
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
local.properties
|
||||||
|
**/build/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
app/build.gradle.kts.backup-*
|
||||||
11
PRIVACY.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Vexta Wallet is a non-custodial wallet.
|
||||||
|
|
||||||
|
Recovery phrases and private keys remain on the users device. The recovery phrase is stored locally in encrypted form using Android Keystore protection.
|
||||||
|
|
||||||
|
The wallet does not require an account and does not intentionally collect names, email addresses, phone numbers, or recovery phrases.
|
||||||
|
|
||||||
|
The wallet connects to Vexta network nodes and the official update endpoint. Network operators may observe standard connection information such as IP address and connection time.
|
||||||
|
|
||||||
|
Users are responsible for securely backing up their recovery phrase.
|
||||||
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Vexta Wallet for Android
|
||||||
|
|
||||||
|
Official open-source Android wallet for the Vexta (VTX) blockchain.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Create and restore a 12-word recovery phrase
|
||||||
|
- Multiple deterministic receiving addresses
|
||||||
|
- Native SegWit Vexta addresses
|
||||||
|
- Verified SPV block headers
|
||||||
|
- BIP158 compact-filter scanning
|
||||||
|
- Local transaction history
|
||||||
|
- Send and receive VTX
|
||||||
|
- QR-code scanning
|
||||||
|
- Encrypted recovery phrase storage
|
||||||
|
- Signed in-app updates with SHA256 verification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 8.0 or newer
|
||||||
|
- Android SDK 36
|
||||||
|
- Java 17
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
Debug APK: app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
## Official release signature
|
||||||
|
|
||||||
|
Certificate SHA256:
|
||||||
|
12b3de2573af4a23fca222ee6609e60c6f685a624f510151128087a8c4d0020e
|
||||||
|
|
||||||
|
## Official links
|
||||||
|
|
||||||
|
- Website: https://vextaproject.org
|
||||||
|
- Explorer: https://vextaproject.org/explorer/
|
||||||
|
- Discord: https://discord.gg/zzpm7ghN3e
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
Keep a secure offline backup of the recovery phrase. This software is provided without warranty.
|
||||||
7
SECURITY.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Report security vulnerabilities privately through official Vexta Project contact channels.
|
||||||
|
|
||||||
|
Never send recovery phrases, private keys, signing keystores, signing passwords, or screenshots containing recovery phrases.
|
||||||
|
|
||||||
|
The Vexta Project will never ask for a recovery phrase or private key.
|
||||||
63
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
FileInputStream(keystorePropertiesFile).use {
|
||||||
|
keystoreProperties.load(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.vextaproject.wallet"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "org.vextaproject.wallet"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 27
|
||||||
|
versionName = "0.4.5"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(
|
||||||
|
keystoreProperties.getProperty("storeFile")
|
||||||
|
)
|
||||||
|
storePassword =
|
||||||
|
keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias =
|
||||||
|
keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword =
|
||||||
|
keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.bitcoinj:bitcoinj-core:0.16.3")
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
implementation(
|
||||||
|
"com.google.android.gms:play-services-code-scanner:16.1.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
3
build.gradle.kts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.3.0" apply false
|
||||||
|
}
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
82
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
18
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "VextaWallet"
|
||||||
|
include(":app")
|
||||||