Add post-quantum wallet support and PQ transaction signing

This commit is contained in:
Vexta Project 2026-09-05 21:22:13 +00:00
commit ba44de3cfd
67 changed files with 10102 additions and 269 deletions

View file

@ -23,13 +23,20 @@ object BlockScanner {
private const val genesisHash =
"0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1"
enum class AddressType {
STANDARD,
MLDSA,
SLHDSA
}
data class SpendableUtxo(
val txid: String,
val outputIndex: Long,
val value: Long,
val height: Int,
val addressIndex: Int,
val isCoinbase: Boolean
val isCoinbase: Boolean,
val addressType: AddressType = AddressType.STANDARD
)
data class WalletTransaction(
@ -59,7 +66,8 @@ object BlockScanner {
val value: Long,
val height: Int,
val addressIndex: Int,
val isCoinbase: Boolean
val isCoinbase: Boolean,
val addressType: AddressType
)
private data class ParsedTransaction(
@ -86,13 +94,17 @@ object BlockScanner {
matchingHeights: List<Int>,
walletScript: ByteArray,
addressIndex: Int,
addressType: AddressType = AddressType.STANDARD,
initialUtxos: List<SpendableUtxo> = emptyList(),
progress: (Int, Int) -> Unit
): Result {
if (matchingHeights.isEmpty()) {
val existing =
initialUtxos
.filter { it.addressIndex == addressIndex }
.filter {
it.addressIndex == addressIndex &&
it.addressType == addressType
}
.sortedWith(
compareBy<SpendableUtxo> { it.height }
.thenBy { it.txid }
@ -128,7 +140,10 @@ object BlockScanner {
val utxos = linkedMapOf<String, Utxo>()
initialUtxos
.filter { it.addressIndex == addressIndex }
.filter {
it.addressIndex == addressIndex &&
it.addressType == addressType
}
.forEach { existing ->
val txidWire =
existing.txid
@ -144,7 +159,8 @@ object BlockScanner {
value = existing.value,
height = existing.height,
addressIndex = existing.addressIndex,
isCoinbase = existing.isCoinbase
isCoinbase = existing.isCoinbase,
addressType = existing.addressType
)
}
var receivedTransactions = 0
@ -241,7 +257,8 @@ object BlockScanner {
value = transactionOutput.value,
height = height,
addressIndex = addressIndex,
isCoinbase = transaction.isCoinbase
isCoinbase = transaction.isCoinbase,
addressType = addressType
)
receivedWalletValue +=
@ -290,7 +307,8 @@ object BlockScanner {
value = utxo.value,
height = utxo.height,
addressIndex = utxo.addressIndex,
isCoinbase = utxo.isCoinbase
isCoinbase = utxo.isCoinbase,
addressType = utxo.addressType
)
}

View file

@ -0,0 +1,39 @@
package org.vextaproject.wallet
object VextaPQ {
init {
System.loadLibrary("vextapq")
}
external fun nativeVersion(): String
external fun mldsaKeypairFromSeed(
seed: ByteArray
): Array<ByteArray>?
external fun sphincsKeypairFromSeed(
seed: ByteArray
): Array<ByteArray>?
external fun mldsaSign(
message: ByteArray,
secretKey: ByteArray
): ByteArray?
external fun mldsaVerify(
signature: ByteArray,
message: ByteArray,
publicKey: ByteArray
): Boolean
external fun sphincsSign(
message: ByteArray,
secretKey: ByteArray
): ByteArray?
external fun sphincsVerify(
signature: ByteArray,
message: ByteArray,
publicKey: ByteArray
): Boolean
}

View file

@ -48,6 +48,13 @@ object VextaTransactionSender {
val fee: Long
)
data class PqSigningKey(
val addressType: BlockScanner.AddressType,
val publicKey: ByteArray,
val secretKey: ByteArray
)
private const val COINBASE_MATURITY = 100
private fun validateCoinbaseMaturity(
@ -90,9 +97,9 @@ object VextaTransactionSender {
Math.addExact(sum, utxo.value)
}
val fee = estimateFee(
inputCount = spendableUtxos.size,
outputCount = 1
val fee = estimateFeeFromOutputVirtualBytes(
inputs = spendableUtxos,
outputVirtualBytes = listOf(43L)
)
val amount = total - fee
@ -112,6 +119,8 @@ object VextaTransactionSender {
recipientAddress: String,
amountSatoshis: Long,
privateKeysByIndex: Map<Int, ECKey>,
pqSigningKeys:
Map<Pair<BlockScanner.AddressType, Int>, PqSigningKey>,
changePubKeyHash: ByteArray,
chainHeight: Int
): CreatedTransaction {
@ -127,19 +136,32 @@ object VextaTransactionSender {
"Invalid change public-key hash"
}
require(privateKeysByIndex.isNotEmpty()) {
"No wallet private keys supplied"
require(
privateKeysByIndex.isNotEmpty() ||
pqSigningKeys.isNotEmpty()
) {
"No wallet signing 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 orderedUtxos =
spendableUtxos
.sortedWith(
compareBy<BlockScanner.SpendableUtxo> {
it.height
}
.thenBy { it.txid }
.thenBy { it.outputIndex }
)
if (orderedUtxos.isEmpty()) {
throw IllegalStateException(
"No spendable outputs available"
)
}
val maxSpend =
calculateMaxSpend(
@ -172,8 +194,11 @@ object VextaTransactionSender {
Math.addExact(selectedValue, utxo.value)
val estimatedFee = estimateFee(
inputCount = selected.size,
outputCount = 2
inputs = selected,
outputScripts = listOf(
recipientScript,
changeScript
)
)
if (
@ -191,8 +216,11 @@ object VextaTransactionSender {
}
fee = estimateFee(
inputCount = selected.size,
outputCount = 2
inputs = selected,
outputScripts = listOf(
recipientScript,
changeScript
)
)
if (selectedValue < amountSatoshis + fee) {
@ -267,37 +295,127 @@ object VextaTransactionSender {
}
selected.forEachIndexed { index, utxo ->
val privateKey =
privateKeysByIndex[utxo.addressIndex]
?: throw IllegalStateException(
"Missing private key for address index " +
utxo.addressIndex
)
when (utxo.addressType) {
BlockScanner.AddressType.STANDARD -> {
val privateKey =
privateKeysByIndex[utxo.addressIndex]
?: throw IllegalStateException(
"Missing private key for address index " +
utxo.addressIndex
)
val walletPubKeyHash =
Utils.sha256hash160(privateKey.pubKey)
val walletPubKeyHash =
Utils.sha256hash160(privateKey.pubKey)
val scriptCode =
ScriptBuilder.createP2PKHOutputScript(
walletPubKeyHash
)
val scriptCode =
ScriptBuilder.createP2PKHOutputScript(
walletPubKeyHash
)
val signature =
transaction.calculateWitnessSignature(
index,
privateKey,
scriptCode,
Coin.valueOf(utxo.value),
Transaction.SigHash.ALL,
false
)
val signature =
transaction.calculateWitnessSignature(
index,
privateKey,
scriptCode,
Coin.valueOf(utxo.value),
Transaction.SigHash.ALL,
false
)
transaction.getInput(index.toLong()).setWitness(
TransactionWitness.redeemP2WPKH(
signature,
privateKey
)
)
transaction
.getInput(index.toLong())
.setWitness(
TransactionWitness.redeemP2WPKH(
signature,
privateKey
)
)
}
BlockScanner.AddressType.MLDSA,
BlockScanner.AddressType.SLHDSA -> {
val pqKey =
pqSigningKeys[
utxo.addressType to
utxo.addressIndex
] ?: throw IllegalStateException(
"Missing PQ signing key for " +
"${utxo.addressType} address index " +
utxo.addressIndex
)
require(
pqKey.addressType == utxo.addressType
) {
"PQ signing key type mismatch"
}
val witnessVersion =
when (utxo.addressType) {
BlockScanner.AddressType.MLDSA -> 2
BlockScanner.AddressType.SLHDSA -> 3
BlockScanner.AddressType.STANDARD ->
error("Standard input is not PQ")
}
val witnessProgram =
MessageDigest
.getInstance("SHA-256")
.digest(pqKey.publicKey)
val signatureHash =
calculatePqrSignatureHash(
selected = selected,
outputs = outputs,
inputIndex = index,
witnessVersion = witnessVersion,
witnessProgram = witnessProgram,
amount = utxo.value,
version = 2,
lockTime = 0L,
inputSequence = sequence
)
val signature =
try {
when (utxo.addressType) {
BlockScanner.AddressType.MLDSA ->
requireNotNull(
VextaPQ.mldsaSign(
signatureHash,
pqKey.secretKey
)
) {
"ML-DSA signing failed"
}
BlockScanner.AddressType.SLHDSA ->
requireNotNull(
VextaPQ.sphincsSign(
signatureHash,
pqKey.secretKey
)
) {
"SLH-DSA signing failed"
}
BlockScanner.AddressType.STANDARD ->
error("Standard input is not PQ")
}
} finally {
pqKey.secretKey.fill(0)
}
transaction
.getInput(index.toLong())
.setWitness(
createPqWitness(
signature,
pqKey.publicKey
)
)
}
}
}
val rawTransaction =
@ -340,6 +458,468 @@ object VextaTransactionSender {
return successfulPeers
}
private fun calculatePqrSignatureHash(
selected: List<BlockScanner.SpendableUtxo>,
outputs: List<TransactionOutputData>,
inputIndex: Int,
witnessVersion: Int,
witnessProgram: ByteArray,
amount: Long,
version: Int = 2,
lockTime: Long = 0L,
inputSequence: Long = sequence
): ByteArray {
require(inputIndex in selected.indices) {
"Invalid PQR input index"
}
require(witnessVersion == 2 || witnessVersion == 3) {
"Invalid PQR witness version"
}
require(witnessProgram.size == 32) {
"Invalid PQR witness program"
}
val hashPrevouts = doubleSha256(
ByteArrayOutputStream().apply {
selected.forEach { utxo ->
write(hashDisplayToWire(utxo.txid))
writeUInt32LE(this, utxo.outputIndex)
}
}.toByteArray()
)
val hashSequence = doubleSha256(
ByteArrayOutputStream().apply {
selected.forEach {
writeUInt32LE(this, inputSequence)
}
}.toByteArray()
)
val hashOutputs = doubleSha256(
ByteArrayOutputStream().apply {
outputs.forEach { transactionOutput ->
writeInt64LE(this, transactionOutput.value)
writeCompactSize(
this,
transactionOutput.script.size.toLong()
)
write(transactionOutput.script)
}
}.toByteArray()
)
val currentInput = selected[inputIndex]
val scriptCode =
byteArrayOf(
if (witnessVersion == 2) {
0x52
} else {
0x53
},
0x20
) + witnessProgram
val preimage = ByteArrayOutputStream().apply {
write(0x00)
writeInt32LE(this, version)
write(hashPrevouts)
write(hashSequence)
write(hashDisplayToWire(currentInput.txid))
writeUInt32LE(this, currentInput.outputIndex)
writeCompactSize(this, scriptCode.size.toLong())
write(scriptCode)
writeInt64LE(this, amount)
writeUInt32LE(this, inputSequence)
write(hashOutputs)
writeUInt32LE(this, lockTime)
writeUInt32LE(this, 1L)
}.toByteArray()
return doubleSha256(preimage)
}
fun runPqrSignatureHashSelfTest(): Pair<String, String> {
val selected = listOf(
BlockScanner.SpendableUtxo(
txid =
"0000000000000000000000000000000000000000000000000000000000000001",
outputIndex = 3L,
value = 987654L,
height = 1,
addressIndex = 0,
isCoinbase = false,
addressType = BlockScanner.AddressType.MLDSA
)
)
val outputs = listOf(
TransactionOutputData(
value = 123456L,
script = byteArrayOf(0x51)
)
)
val program =
ByteArray(32) { index ->
index.toByte()
}
val v2 = calculatePqrSignatureHash(
selected = selected,
outputs = outputs,
inputIndex = 0,
witnessVersion = 2,
witnessProgram = program,
amount = 987654L,
version = 2,
lockTime = 500L,
inputSequence = 0xfffffffeL
)
val v3 = calculatePqrSignatureHash(
selected = selected,
outputs = outputs,
inputIndex = 0,
witnessVersion = 3,
witnessProgram = program,
amount = 987654L,
version = 2,
lockTime = 500L,
inputSequence = 0xfffffffeL
)
val v2CoreHex = v2.reversedArray().toHex()
val v3CoreHex = v3.reversedArray().toHex()
check(
v2CoreHex ==
"ba39c447b4577db974473a1e7ed2182686672f2900045a40aee6b4dc5811995b"
) {
"PQR V2 signature hash self-test failed: $v2CoreHex"
}
check(
v3CoreHex ==
"dab3188530f1094ef87087451da77bfba77dfc6e63d1730bb9d0475cebe76618"
) {
"PQR V3 signature hash self-test failed: $v3CoreHex"
}
return v2CoreHex to v3CoreHex
}
private fun createPqWitness(
signature: ByteArray,
publicKey: ByteArray
): TransactionWitness {
require(signature.isNotEmpty()) {
"PQ signature is empty"
}
require(publicKey.isNotEmpty()) {
"PQ public key is empty"
}
return TransactionWitness(2).apply {
setPush(0, signature)
setPush(1, publicKey)
}
}
fun runPqWitnessSelfTest(): List<String> {
val seed =
ByteArray(64) { index ->
index.toByte()
}
val mldsa =
requireNotNull(
VextaPQ.mldsaKeypairFromSeed(seed)
)
val sphincs =
requireNotNull(
VextaPQ.sphincsKeypairFromSeed(seed)
)
val message =
"Vexta PQ witness self-test"
.toByteArray(Charsets.UTF_8)
val mldsaSignature =
requireNotNull(
VextaPQ.mldsaSign(
message,
mldsa[1]
)
)
val sphincsSignature =
requireNotNull(
VextaPQ.sphincsSign(
message,
sphincs[1]
)
)
val mldsaWitness =
createPqWitness(
mldsaSignature,
mldsa[0]
)
val sphincsWitness =
createPqWitness(
sphincsSignature,
sphincs[0]
)
check(mldsaWitness.pushCount == 2)
check(mldsaWitness.getPush(0).size == 3309)
check(mldsaWitness.getPush(1).size == 1952)
check(sphincsWitness.pushCount == 2)
check(sphincsWitness.getPush(0).size == 7856)
check(sphincsWitness.getPush(1).size == 32)
mldsa[1].fill(0)
sphincs[1].fill(0)
return listOf(
"MLDSA_WITNESS_PUSHES=${mldsaWitness.pushCount}",
"MLDSA_SIG_BYTES=${mldsaWitness.getPush(0).size}",
"MLDSA_PUBKEY_BYTES=${mldsaWitness.getPush(1).size}",
"SLHDSA_WITNESS_PUSHES=${sphincsWitness.pushCount}",
"SLHDSA_SIG_BYTES=${sphincsWitness.getPush(0).size}",
"SLHDSA_PUBKEY_BYTES=${sphincsWitness.getPush(1).size}",
"PQ_WITNESS_RESULT=PASS"
)
}
fun runPqSignedTransactionSelfTest(): List<String> {
val recipientAddress =
"vtx1z6engqms3emse5lycna6yt7gd6svu7nfd28dccr7mfs8s54pz8rysd6lu5u"
val recipientScript =
addressToScript(recipientAddress)
val changePubKeyHash =
ByteArray(20) { index ->
(0x40 + index).toByte()
}
val changeScript =
byteArrayOf(0x00, 0x14) + changePubKeyHash
val amountSatoshis = 100_000L
val utxoValue = 10_000_000L
fun testType(
addressType: BlockScanner.AddressType,
witnessVersion: Int,
expectedSignatureBytes: Int,
expectedPublicKeyBytes: Int,
txidSuffix: String
): List<String> {
val seed =
ByteArray(64) { index ->
index.toByte()
}
val keyPair =
when (addressType) {
BlockScanner.AddressType.MLDSA ->
requireNotNull(
VextaPQ.mldsaKeypairFromSeed(seed)
)
BlockScanner.AddressType.SLHDSA ->
requireNotNull(
VextaPQ.sphincsKeypairFromSeed(seed)
)
BlockScanner.AddressType.STANDARD ->
error("Standard is not a PQ self-test type")
}
require(keyPair.size == 2)
val publicKey = keyPair[0]
val secretKey = keyPair[1]
val syntheticTxid =
txidSuffix.padStart(64, '0')
val utxo =
BlockScanner.SpendableUtxo(
txid = syntheticTxid,
outputIndex = 0L,
value = utxoValue,
height = 1,
addressIndex = 0,
isCoinbase = false,
addressType = addressType
)
val pqSigningKeys =
mapOf(
(addressType to 0) to
PqSigningKey(
addressType = addressType,
publicKey = publicKey,
secretKey = secretKey
)
)
val created =
createAndSign(
spendableUtxos = listOf(utxo),
recipientAddress = recipientAddress,
amountSatoshis = amountSatoshis,
privateKeysByIndex = emptyMap(),
pqSigningKeys = pqSigningKeys,
changePubKeyHash = changePubKeyHash,
chainHeight = 10_000
)
val parsed =
Transaction(
MainNetParams.get(),
created.rawTransaction
)
check(parsed.inputs.size == 1)
val witness =
parsed.getInput(0L).getWitness()
check(witness.pushCount == 2)
val signature =
witness.getPush(0)
val parsedPublicKey =
witness.getPush(1)
check(signature.size == expectedSignatureBytes)
check(parsedPublicKey.size == expectedPublicKeyBytes)
check(parsedPublicKey.contentEquals(publicKey))
check(
parsed.txId.toString() ==
created.txid
)
val witnessProgram =
MessageDigest
.getInstance("SHA-256")
.digest(publicKey)
val outputs =
mutableListOf(
TransactionOutputData(
value = created.amount,
script = recipientScript
)
)
if (created.change > 0L) {
outputs.add(
TransactionOutputData(
value = created.change,
script = changeScript
)
)
}
val signatureHash =
calculatePqrSignatureHash(
selected = listOf(utxo),
outputs = outputs,
inputIndex = 0,
witnessVersion = witnessVersion,
witnessProgram = witnessProgram,
amount = utxo.value,
version = 2,
lockTime = 0L,
inputSequence = sequence
)
val verified =
when (addressType) {
BlockScanner.AddressType.MLDSA ->
VextaPQ.mldsaVerify(
signature,
signatureHash,
publicKey
)
BlockScanner.AddressType.SLHDSA ->
VextaPQ.sphincsVerify(
signature,
signatureHash,
publicKey
)
BlockScanner.AddressType.STANDARD ->
false
}
check(verified)
seed.fill(0)
signatureHash.fill(0)
return listOf(
"${addressType.name}_SIGNED_TX_BYTES=" +
created.rawTransaction.size,
"${addressType.name}_SIGNED_TX_WITNESS_PUSHES=" +
witness.pushCount,
"${addressType.name}_SIGNED_TX_SIG_BYTES=" +
signature.size,
"${addressType.name}_SIGNED_TX_PUBKEY_BYTES=" +
parsedPublicKey.size,
"${addressType.name}_SIGNED_TX_VERIFY=$verified",
"${addressType.name}_SIGNED_TX_TXID_MATCH=true"
)
}
val results =
mutableListOf<String>()
results +=
testType(
addressType = BlockScanner.AddressType.MLDSA,
witnessVersion = 2,
expectedSignatureBytes = 3309,
expectedPublicKeyBytes = 1952,
txidSuffix = "01"
)
results +=
testType(
addressType = BlockScanner.AddressType.SLHDSA,
witnessVersion = 3,
expectedSignatureBytes = 7856,
expectedPublicKeyBytes = 32,
txidSuffix = "02"
)
results += "PQ_SIGNED_TX_RESULT=PASS"
return results
}
private fun serializeTransaction(
selected: List<BlockScanner.SpendableUtxo>,
outputs: List<TransactionOutputData>,
@ -401,14 +981,64 @@ object VextaTransactionSender {
}.toByteArray()
}
private fun estimateFee(
inputCount: Int,
outputCount: Int
private fun estimatedInputVirtualBytes(
addressType: BlockScanner.AddressType
): Long {
return when (addressType) {
BlockScanner.AddressType.STANDARD ->
69L
BlockScanner.AddressType.MLDSA ->
1358L
BlockScanner.AddressType.SLHDSA ->
2015L
}
}
private fun estimatedOutputVirtualBytes(
script: ByteArray
): Long {
val scriptLengthBytes =
when {
script.size < 253 -> 1L
script.size <= 0xffff -> 3L
else -> 5L
}
return 8L +
scriptLengthBytes +
script.size.toLong()
}
private fun estimateFee(
inputs: List<BlockScanner.SpendableUtxo>,
outputScripts: List<ByteArray>
): Long {
return estimateFeeFromOutputVirtualBytes(
inputs = inputs,
outputVirtualBytes =
outputScripts.map {
estimatedOutputVirtualBytes(it)
}
)
}
private fun estimateFeeFromOutputVirtualBytes(
inputs: List<BlockScanner.SpendableUtxo>,
outputVirtualBytes: List<Long>
): Long {
val inputVirtualBytes =
inputs.sumOf {
estimatedInputVirtualBytes(
it.addressType
)
}
val virtualBytes =
11L +
inputCount * 69L +
outputCount * 31L
inputVirtualBytes +
outputVirtualBytes.sum()
return (
virtualBytes * defaultFeeSatoshisPerKb + 999L
@ -419,13 +1049,33 @@ object VextaTransactionSender {
val value = address.trim()
if (value.lowercase().startsWith("vtx1")) {
val witnessProgram = decodeBech32Witness(value)
val (witnessVersion, witnessProgram) =
decodeBech32Witness(value)
require(witnessProgram.size == 20) {
"Only Vexta P2WPKH addresses are supported"
return when (witnessVersion) {
0 -> {
require(witnessProgram.size == 20) {
"Only Vexta P2WPKH witness v0 addresses are supported"
}
byteArrayOf(0x00, 0x14) + witnessProgram
}
2 -> {
require(witnessProgram.size == 32) {
"Invalid ML-DSA witness program"
}
byteArrayOf(0x52, 0x20) + witnessProgram
}
3 -> {
require(witnessProgram.size == 32) {
"Invalid SLH-DSA witness program"
}
byteArrayOf(0x53, 0x20) + witnessProgram
}
else -> error("Unsupported witness version")
}
return byteArrayOf(0x00, 0x14) + witnessProgram
}
val decoded = decodeBase58Check(value)
@ -452,7 +1102,9 @@ object VextaTransactionSender {
)
}
private fun decodeBech32Witness(address: String): ByteArray {
private fun decodeBech32Witness(
address: String
): Pair<Int, ByteArray> {
require(address == address.lowercase()) {
"Mixed-case Bech32 address"
}
@ -477,21 +1129,38 @@ object VextaTransactionSender {
index
}
require(bech32Polymod(expandHrp(hrp) + values) == 1) {
"Invalid Bech32 checksum"
val data = values.dropLast(6)
require(data.isNotEmpty()) {
"Missing witness version"
}
val data = values.dropLast(6)
require(data.isNotEmpty() && data[0] == 0) {
val witnessVersion = data[0]
require(witnessVersion in 0..16) {
"Unsupported witness version"
}
return convertBits(
val expectedChecksum =
if (witnessVersion == 0) 1 else 0x2bc830a3
require(
bech32Polymod(expandHrp(hrp) + values) ==
expectedChecksum
) {
if (witnessVersion == 0) {
"Invalid Bech32 checksum"
} else {
"Invalid Bech32m checksum"
}
}
val witnessProgram = convertBits(
data.drop(1),
5,
8,
false
).map { it.toByte() }.toByteArray()
return witnessVersion to witnessProgram
}
private fun convertBits(

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,14 @@ class WalletMonitoringService : Service() {
private const val PREF_IV = "seed_iv"
private const val PREF_RECEIVE_ADDRESS_INDEX =
"receive_address_index"
private const val PREF_MLDSA_ADDRESS_INDEX =
"mldsa_address_index"
private const val PREF_SLHDSA_ADDRESS_INDEX =
"slhdsa_address_index"
private const val PREF_MLDSA_ADDRESS_CREATED =
"mldsa_address_created"
private const val PREF_SLHDSA_ADDRESS_CREATED =
"slhdsa_address_created"
private const val PREF_KNOWN_INCOMING_TXIDS =
"known_incoming_txids"
private const val PREF_TX_NOTIFICATIONS_INITIALIZED =
@ -54,6 +62,16 @@ class WalletMonitoringService : Service() {
private const val BACKGROUND_TX_HISTORY_VERSION = 4
}
private fun backgroundUtxoKey(
addressType: BlockScanner.AddressType,
addressIndex: Int
): String {
return PREF_BACKGROUND_UTXO_PREFIX +
addressType.name +
"_" +
addressIndex
}
private val handler = Handler(Looper.getMainLooper())
private var scanRunning = false
@ -136,6 +154,16 @@ class WalletMonitoringService : Service() {
.getInt(PREF_RECEIVE_ADDRESS_INDEX, 0)
.coerceAtLeast(0)
val highestMldsaAddressIndex =
preferences
.getInt(PREF_MLDSA_ADDRESS_INDEX, 0)
.coerceAtLeast(0)
val highestSlhdsaAddressIndex =
preferences
.getInt(PREF_SLHDSA_ADDRESS_INDEX, 0)
.coerceAtLeast(0)
val localHeight = chain.last().height
preferences.edit()
@ -153,14 +181,45 @@ class WalletMonitoringService : Service() {
)
.coerceAtLeast(0)
val backgroundCacheAvailable =
val standardCacheAvailable =
(0..highestAddressIndex).all { addressIndex ->
preferences.contains(
PREF_BACKGROUND_UTXO_PREFIX +
backgroundUtxoKey(
BlockScanner.AddressType.STANDARD,
addressIndex
)
) ||
preferences.contains(
PREF_BACKGROUND_UTXO_PREFIX +
addressIndex
)
}
val mldsaCacheAvailable =
(0..highestMldsaAddressIndex).all { addressIndex ->
preferences.contains(
backgroundUtxoKey(
BlockScanner.AddressType.MLDSA,
addressIndex
)
)
}
val slhdsaCacheAvailable =
(0..highestSlhdsaAddressIndex).all { addressIndex ->
preferences.contains(
backgroundUtxoKey(
BlockScanner.AddressType.SLHDSA,
addressIndex
)
)
}
val backgroundCacheAvailable =
standardCacheAvailable &&
mldsaCacheAvailable &&
slhdsaCacheAvailable
val transactionHistoryCurrent =
preferences.getInt(
PREF_BACKGROUND_TX_HISTORY_VERSION,
@ -192,12 +251,79 @@ class WalletMonitoringService : Service() {
mutableListOf<BlockScanner.WalletTransaction>()
val updatedUtxos =
linkedMapOf<Int, List<BlockScanner.SpendableUtxo>>()
linkedMapOf<
Pair<BlockScanner.AddressType, Int>,
List<BlockScanner.SpendableUtxo>
>()
for (addressIndex in 0..highestAddressIndex) {
val script =
deriveWitnessScript(words, addressIndex)
val scanTargets =
buildList {
for (addressIndex in 0..highestAddressIndex) {
add(
Triple(
BlockScanner.AddressType.STANDARD,
addressIndex,
deriveWitnessScript(
words,
addressIndex
)
)
)
}
if (
preferences.getBoolean(
PREF_MLDSA_ADDRESS_CREATED,
false
)
) {
for (
addressIndex in
0..highestMldsaAddressIndex
) {
add(
Triple(
BlockScanner.AddressType.MLDSA,
addressIndex,
derivePqWitnessScript(
words,
BlockScanner.AddressType.MLDSA,
addressIndex
)
)
)
}
}
if (
preferences.getBoolean(
PREF_SLHDSA_ADDRESS_CREATED,
false
)
) {
for (
addressIndex in
0..highestSlhdsaAddressIndex
) {
add(
Triple(
BlockScanner.AddressType.SLHDSA,
addressIndex,
derivePqWitnessScript(
words,
BlockScanner.AddressType.SLHDSA,
addressIndex
)
)
)
}
}
}
for (
(addressType, addressIndex, script)
in scanTargets
) {
val filters =
CompactFilterClient.scan(
this,
@ -212,6 +338,7 @@ class WalletMonitoringService : Service() {
} else {
loadBackgroundUtxos(
preferences,
addressType,
addressIndex
)
}
@ -223,13 +350,15 @@ class WalletMonitoringService : Service() {
filters.matchingHeights,
script,
addressIndex,
addressType = addressType,
initialUtxos = initialUtxos
) { _, _ -> }
allTransactions.addAll(blocks.transactions)
updatedUtxos[addressIndex] =
blocks.utxos
updatedUtxos[
addressType to addressIndex
] = blocks.utxos
}
val mergedTransactions =
@ -297,15 +426,16 @@ class WalletMonitoringService : Service() {
)
for (
addressIndex in 0..highestAddressIndex
(target, utxos) in updatedUtxos
) {
val (addressType, addressIndex) = target
editor.putStringSet(
PREF_BACKGROUND_UTXO_PREFIX +
addressIndex,
encodeBackgroundUtxos(
updatedUtxos[addressIndex]
?: emptyList()
)
backgroundUtxoKey(
addressType,
addressIndex
),
encodeBackgroundUtxos(utxos)
)
}
@ -536,25 +666,47 @@ class WalletMonitoringService : Service() {
utxo.value.toString(),
utxo.height.toString(),
utxo.addressIndex.toString(),
utxo.isCoinbase.toString()
utxo.isCoinbase.toString(),
utxo.addressType.name
).joinToString("|")
}.toSet()
private fun loadBackgroundUtxos(
preferences: android.content.SharedPreferences,
addressType: BlockScanner.AddressType,
addressIndex: Int
): List<BlockScanner.SpendableUtxo> {
val newKey =
backgroundUtxoKey(
addressType,
addressIndex
)
val legacyKey =
PREF_BACKGROUND_UTXO_PREFIX + addressIndex
val values =
preferences.getStringSet(
PREF_BACKGROUND_UTXO_PREFIX + addressIndex,
if (preferences.contains(newKey)) {
preferences.getStringSet(
newKey,
emptySet()
) ?: emptySet()
} else if (
addressType == BlockScanner.AddressType.STANDARD
) {
preferences.getStringSet(
legacyKey,
emptySet()
) ?: emptySet()
} else {
emptySet()
) ?: emptySet()
}
return values.mapNotNull { value ->
try {
val parts = value.split("|")
if (parts.size != 6) {
if (parts.size != 6 && parts.size != 7) {
return@mapNotNull null
}
@ -564,7 +716,13 @@ class WalletMonitoringService : Service() {
value = parts[2].toLong(),
height = parts[3].toInt(),
addressIndex = parts[4].toInt(),
isCoinbase = parts[5].toBooleanStrict()
isCoinbase = parts[5].toBooleanStrict(),
addressType =
if (parts.size == 7) {
BlockScanner.AddressType.valueOf(parts[6])
} else {
BlockScanner.AddressType.STANDARD
}
)
} catch (_: Exception) {
null
@ -619,6 +777,129 @@ class WalletMonitoringService : Service() {
.toString(Charsets.UTF_8)
}
private fun derivePqWitnessScript(
words: List<String>,
addressType: BlockScanner.AddressType,
addressIndex: Int
): ByteArray {
require(
addressType == BlockScanner.AddressType.MLDSA ||
addressType == BlockScanner.AddressType.SLHDSA
)
require(addressIndex >= 0)
MnemonicCode.INSTANCE.check(words)
val bip39Seed =
MnemonicCode.toSeed(words, "")
val masterKey =
HDKeyDerivation.createMasterPrivateKey(
bip39Seed
)
val walletSeed =
masterKey.privKeyBytes
val domain =
"Vexta-PQ-HD-v1"
.toByteArray(Charsets.US_ASCII)
val data =
ByteArray(domain.size + 1 + 4)
System.arraycopy(
domain,
0,
data,
0,
domain.size
)
var offset = domain.size
data[offset++] =
when (addressType) {
BlockScanner.AddressType.MLDSA -> 2
BlockScanner.AddressType.SLHDSA -> 3
BlockScanner.AddressType.STANDARD ->
error("Standard address is not PQ")
}.toByte()
data[offset++] =
((addressIndex ushr 24) and 0xff).toByte()
data[offset++] =
((addressIndex ushr 16) and 0xff).toByte()
data[offset++] =
((addressIndex ushr 8) and 0xff).toByte()
data[offset] =
(addressIndex and 0xff).toByte()
val mac =
javax.crypto.Mac.getInstance(
"HmacSHA512"
)
mac.init(
javax.crypto.spec.SecretKeySpec(
walletSeed,
"HmacSHA512"
)
)
val seedMaterial =
mac.doFinal(data)
val keyPair =
when (addressType) {
BlockScanner.AddressType.MLDSA ->
requireNotNull(
VextaPQ.mldsaKeypairFromSeed(
seedMaterial
)
)
BlockScanner.AddressType.SLHDSA ->
requireNotNull(
VextaPQ.sphincsKeypairFromSeed(
seedMaterial
)
)
BlockScanner.AddressType.STANDARD ->
error("Standard address is not PQ")
}
require(keyPair.size == 2)
val publicKey = keyPair[0]
val secretKey = keyPair[1]
return try {
val keyId =
java.security.MessageDigest
.getInstance("SHA-256")
.digest(publicKey)
byteArrayOf(
if (
addressType ==
BlockScanner.AddressType.MLDSA
) {
0x52.toByte()
} else {
0x53.toByte()
},
0x20
) + keyId
} finally {
secretKey.fill(0)
seedMaterial.fill(0)
walletSeed.fill(0)
bip39Seed.fill(0)
}
}
private fun deriveWitnessScript(
words: List<String>,
addressIndex: Int