Release Vexta Wallet v0.6.0
This commit is contained in:
parent
e513de0d7c
commit
680a81cbe2
5 changed files with 833 additions and 13 deletions
|
|
@ -22,8 +22,8 @@ android {
|
||||||
applicationId = "org.vextaproject.wallet"
|
applicationId = "org.vextaproject.wallet"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 29
|
versionCode = 30
|
||||||
versionName = "0.5.1"
|
versionName = "0.6.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -55,6 +55,8 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
||||||
implementation("org.bitcoinj:bitcoinj-core:0.16.3")
|
implementation("org.bitcoinj:bitcoinj-core:0.16.3")
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
implementation(
|
implementation(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -29,6 +31,15 @@
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:excludeFromRecents="true" />
|
android:excludeFromRecents="true" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".WalletMonitoringService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Monitors Vexta wallet for incoming blockchain payments while the app is in the background." />
|
||||||
|
</service>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
android:value="barcode_ui" />
|
android:value="barcode_ui" />
|
||||||
|
|
|
||||||
594
app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
Normal file
594
app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
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.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
|
||||||
|
|
||||||
|
object HeaderSync {
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
fun loadCachedChain(context: Context): 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(context.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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveChain(context: Context, chain: List<ChainHeader>) {
|
||||||
|
val file = File(context.filesDir, "vexta-headers.dat")
|
||||||
|
val temporary = File(context.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeVarString(out: ByteArrayOutputStream, value: String) {
|
||||||
|
val bytes = value.toByteArray(Charsets.UTF_8)
|
||||||
|
writeCompactSize(out, bytes.size)
|
||||||
|
out.write(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hashHexToWireBytes(hash: String): ByteArray =
|
||||||
|
hash.removePrefix("0x")
|
||||||
|
.chunked(2)
|
||||||
|
.map { it.toInt(16).toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
.reversedArray()
|
||||||
|
|
||||||
|
fun ByteArray.toHex(): String =
|
||||||
|
joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
|
||||||
|
fun writeInt32LE(out: ByteArrayOutputStream, value: Int) {
|
||||||
|
out.write(
|
||||||
|
ByteBuffer.allocate(4)
|
||||||
|
.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.putInt(value)
|
||||||
|
.array()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeInt64LE(out: ByteArrayOutputStream, value: Long) {
|
||||||
|
out.write(
|
||||||
|
ByteBuffer.allocate(8)
|
||||||
|
.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.putLong(value)
|
||||||
|
.array()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun doubleSha256(data: ByteArray): ByteArray {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
return digest.digest(digest.digest(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package org.vextaproject.wallet
|
package org.vextaproject.wallet
|
||||||
|
|
||||||
import android.app.Activity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
|
|
@ -66,7 +69,7 @@ import javax.crypto.KeyGenerator
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
class WalletActivity : Activity() {
|
class WalletActivity : FragmentActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS = "vexta_wallet"
|
private const val PREFS = "vexta_wallet"
|
||||||
|
|
@ -124,11 +127,14 @@ class WalletActivity : Activity() {
|
||||||
if (
|
if (
|
||||||
!isFinishing &&
|
!isFinishing &&
|
||||||
!isDestroyed &&
|
!isDestroyed &&
|
||||||
hasWindowFocus() &&
|
|
||||||
walletExists() &&
|
walletExists() &&
|
||||||
walletBalanceView != null
|
!blockchainScanRunning
|
||||||
) {
|
) {
|
||||||
startHeaderSyncAndScan()
|
if (hasWindowFocus()) {
|
||||||
|
startHeaderSyncAndScan()
|
||||||
|
} else {
|
||||||
|
startBackgroundHeaderSyncAndScan()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshHandler.postDelayed(this, 60_000L)
|
refreshHandler.postDelayed(this, 60_000L)
|
||||||
|
|
@ -238,6 +244,15 @@ class WalletActivity : Activity() {
|
||||||
createPaymentNotificationChannel()
|
createPaymentNotificationChannel()
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|
||||||
|
val monitoringIntent =
|
||||||
|
Intent(this, WalletMonitoringService::class.java)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(monitoringIntent)
|
||||||
|
} else {
|
||||||
|
startService(monitoringIntent)
|
||||||
|
}
|
||||||
|
|
||||||
val filter = IntentFilter(
|
val filter = IntentFilter(
|
||||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE
|
DownloadManager.ACTION_DOWNLOAD_COMPLETE
|
||||||
)
|
)
|
||||||
|
|
@ -254,7 +269,12 @@ class WalletActivity : Activity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (walletExists()) {
|
if (walletExists()) {
|
||||||
showMainWallet()
|
authenticateWallet(
|
||||||
|
title = "Unlock Vexta Wallet",
|
||||||
|
subtitle = "Use biometrics or your device screen lock"
|
||||||
|
) {
|
||||||
|
showMainWallet()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showWelcome()
|
showWelcome()
|
||||||
}
|
}
|
||||||
|
|
@ -680,6 +700,39 @@ class WalletActivity : Activity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startBackgroundHeaderSyncAndScan() {
|
||||||
|
if (blockchainScanRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blockchainScanRunning = true
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val chain = HeaderSync.loadCachedChain(this)
|
||||||
|
|
||||||
|
for (peer in listOf("87.106.99.23", "74.208.53.160")) {
|
||||||
|
try {
|
||||||
|
val result = HeaderSync.syncFromPeer(peer, chain)
|
||||||
|
|
||||||
|
if (result.received > 0) {
|
||||||
|
HeaderSync.saveChain(this, chain)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnUiThread {
|
||||||
|
runBlockchainScan()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
runOnUiThread {
|
||||||
|
blockchainScanRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
private fun startHeaderSyncAndScan() {
|
private fun startHeaderSyncAndScan() {
|
||||||
if (blockchainScanRunning) {
|
if (blockchainScanRunning) {
|
||||||
return
|
return
|
||||||
|
|
@ -1878,8 +1931,12 @@ class WalletActivity : Activity() {
|
||||||
return@primaryButton
|
return@primaryButton
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
authenticateWallet(
|
||||||
val words = loadMnemonic()
|
title = "Authorize payment",
|
||||||
|
subtitle = "Authenticate before signing this VTX transaction"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val words = loadMnemonic()
|
||||||
.trim()
|
.trim()
|
||||||
.split(Regex("\\s+"))
|
.split(Regex("\\s+"))
|
||||||
|
|
||||||
|
|
@ -2093,6 +2150,7 @@ class WalletActivity : Activity() {
|
||||||
),
|
),
|
||||||
Toast.LENGTH_LONG
|
Toast.LENGTH_LONG
|
||||||
).show()
|
).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2226,6 +2284,93 @@ class WalletActivity : Activity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun authenticateWallet(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val authenticators =
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
|
||||||
|
val biometricManager = BiometricManager.from(this)
|
||||||
|
|
||||||
|
if (
|
||||||
|
biometricManager.canAuthenticate(authenticators) !=
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
) {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Device security required")
|
||||||
|
.setMessage(
|
||||||
|
"Vexta Wallet requires biometrics or a device " +
|
||||||
|
"PIN, pattern, or password. Configure a screen " +
|
||||||
|
"lock in Android security settings and try again."
|
||||||
|
)
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.setPositiveButton("Security settings") { _, _ ->
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val executor = ContextCompat.getMainExecutor(this)
|
||||||
|
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
this,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult
|
||||||
|
) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
super.onAuthenticationFailed()
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
this@WalletActivity,
|
||||||
|
"Authentication not recognized",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
errorCode: Int,
|
||||||
|
errString: CharSequence
|
||||||
|
) {
|
||||||
|
super.onAuthenticationError(
|
||||||
|
errorCode,
|
||||||
|
errString
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||||
|
) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@WalletActivity,
|
||||||
|
errString,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setAllowedAuthenticators(authenticators)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
prompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
|
|
||||||
private fun confirmShowSeed() {
|
private fun confirmShowSeed() {
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("Security warning")
|
.setTitle("Security warning")
|
||||||
|
|
@ -2234,9 +2379,14 @@ class WalletActivity : Activity() {
|
||||||
"Make sure nobody can see your screen."
|
"Make sure nobody can see your screen."
|
||||||
)
|
)
|
||||||
.setNegativeButton("Cancel", null)
|
.setNegativeButton("Cancel", null)
|
||||||
.setPositiveButton("Show") { _, _ ->
|
.setPositiveButton("Authenticate") { _, _ ->
|
||||||
val words = loadMnemonic().split(" ")
|
authenticateWallet(
|
||||||
showSeedBackup(words)
|
title = "Show recovery phrase",
|
||||||
|
subtitle = "Authenticate to reveal your recovery phrase"
|
||||||
|
) {
|
||||||
|
val words = loadMnemonic().split(" ")
|
||||||
|
showSeedBackup(words)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package org.vextaproject.wallet
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
|
||||||
|
class WalletMonitoringService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "vexta_wallet_monitoring"
|
||||||
|
private const val NOTIFICATION_ID = 7001
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
val manager =
|
||||||
|
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Wallet monitoring",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val intent = Intent(this, WalletActivity::class.java)
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
|
.setContentTitle("Vexta Wallet")
|
||||||
|
.setContentText("Monitoring active")
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(
|
||||||
|
intent: Intent?,
|
||||||
|
flags: Int,
|
||||||
|
startId: Int
|
||||||
|
): Int {
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue