lib: make output public key optional in Enote

This commit is contained in:
Oscar Mira 2023-10-19 01:44:22 +02:00
parent 75b33a24f3
commit 7dba0c1b18
7 changed files with 125 additions and 97 deletions

View File

@ -178,7 +178,8 @@ void Wallet::captureTxHistorySnapshot(std::vector<TxInfo>& snapshot) {
for (const auto& td: tds) {
snapshot.emplace_back(td.m_txid, TxInfo::INCOMING);
TxInfo& recv = snapshot.back();
recv.m_key = td.get_public_key();
recv.m_public_key = td.get_public_key();
recv.m_public_key_known = true;
recv.m_key_image = td.m_key_image;
recv.m_key_image_known = td.m_key_image_known;
recv.m_subaddress_major = td.m_subaddr_index.major;
@ -247,7 +248,6 @@ void Wallet::captureTxHistorySnapshot(std::vector<TxInfo>& snapshot) {
// Add pending transfers to our own wallet.
snapshot.emplace_back(pair.first, TxInfo::INCOMING);
TxInfo& recv = snapshot.back();
// TODO: recv.m_key
recv.m_recipient = m_wallet.get_subaddress_as_str(*dest_subaddr_idx);
recv.m_subaddress_major = (*dest_subaddr_idx).major;
recv.m_subaddress_minor = (*dest_subaddr_idx).minor;
@ -273,7 +273,6 @@ void Wallet::captureTxHistorySnapshot(std::vector<TxInfo>& snapshot) {
if (utx.m_change > 0) {
snapshot.emplace_back(pair.first, TxInfo::INCOMING);
TxInfo& change = snapshot.back();
// TODO: change.m_key
change.m_recipient = m_wallet.get_subaddress_as_str({utx.m_subaddr_account, 0});
change.m_subaddress_major = utx.m_subaddr_account;
change.m_subaddress_minor = 0; // All changes go to 0-th subaddress
@ -306,7 +305,6 @@ void Wallet::captureTxHistorySnapshot(std::vector<TxInfo>& snapshot) {
for (uint64_t amount: upd.m_amounts) {
snapshot.emplace_back(upd.m_tx_hash, TxInfo::INCOMING);
TxInfo& recv = snapshot.back();
// TODO: recv.m_key
recv.m_recipient = m_wallet.get_subaddress_as_str(upd.m_subaddr_index);
recv.m_subaddress_major = upd.m_subaddr_index.major;
recv.m_subaddress_minor = upd.m_subaddr_index.minor;
@ -569,25 +567,26 @@ Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight(
}
ScopedJvmLocalRef<jobject> nativeToJvmTxInfo(JNIEnv* env,
const TxInfo& info) {
LOG_FATAL_IF(info.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached");
const TxInfo& tx) {
LOG_FATAL_IF(tx.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached");
// TODO: Check amount overflow
return {env, TxInfoClass.newObject(
env, TxInfo_ctor,
nativeToJvmString(env, pod_to_hex(info.m_tx_hash)).obj(),
nativeToJvmString(env, pod_to_hex(info.m_key)).obj(),
info.m_key_image_known ? nativeToJvmString(env, pod_to_hex(info.m_key_image)).obj(): nullptr,
info.m_subaddress_major,
info.m_subaddress_minor,
(!info.m_recipient.empty()) ? nativeToJvmString(env, info.m_recipient).obj() : nullptr,
info.m_amount,
static_cast<jint>(info.m_height),
info.m_state,
info.m_unlock_time,
info.m_timestamp,
info.m_fee,
info.m_coinbase,
info.m_type == TxInfo::INCOMING)
nativeToJvmString(env, pod_to_hex(tx.m_tx_hash)).obj(),
tx.m_public_key_known ? nativeToJvmString(env, pod_to_hex(tx.m_public_key)).obj() : nullptr,
tx.m_key_image_known ? nativeToJvmString(env, pod_to_hex(tx.m_key_image)).obj() : nullptr,
tx.m_subaddress_major,
tx.m_subaddress_minor,
(!tx.m_recipient.empty()) ? nativeToJvmString(env, tx.m_recipient).obj() : nullptr,
tx.m_amount,
static_cast<jint>(tx.m_height),
tx.m_state,
tx.m_unlock_time,
tx.m_timestamp,
tx.m_fee,
tx.m_change,
tx.m_coinbase,
tx.m_type == TxInfo::INCOMING)
};
}

View File

@ -19,7 +19,7 @@ using unconfirmed_transfer_details = tools::wallet2::unconfirmed_transfer_detail
// Basic structure combining transaction details with input or output info.
struct TxInfo {
crypto::hash m_tx_hash;
crypto::public_key m_key;
crypto::public_key m_public_key;
crypto::key_image m_key_image;
uint32_t m_subaddress_major;
uint32_t m_subaddress_minor;
@ -31,6 +31,7 @@ struct TxInfo {
uint64_t m_fee;
uint64_t m_change;
bool m_coinbase;
bool m_public_key_known;
bool m_key_image_known;
enum TxType {
@ -47,7 +48,7 @@ struct TxInfo {
TxInfo(crypto::hash tx_hash, TxType type):
m_tx_hash(tx_hash),
m_key(crypto::public_key{}),
m_public_key(crypto::public_key{}),
m_key_image(crypto::key_image{}),
m_subaddress_major(-1),
m_subaddress_minor(-1),
@ -59,6 +60,7 @@ struct TxInfo {
m_fee(0),
m_change(0),
m_coinbase(false),
m_public_key_known(false),
m_key_image_known(false),
m_type(type),
m_state(OFF_CHAIN) {}

View File

@ -13,7 +13,7 @@ data class Block(
data class BlockHeader(
val height: Int,
val timestamp: Long,
val epochSecond: Long,
) {
companion object {
const val MAX_HEIGHT = CRYPTONOTE_MAX_BLOCK_NUMBER - 1

View File

@ -25,6 +25,8 @@ open class BlockchainTime(
override fun compareTo(other: BlockchainTime): Int =
this.height.compareTo(other.height)
override fun toString(): String = "Block #$height | $timestamp"
data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193))
class Block(height: Int, referencePoint: BlockchainTime = Genesis) :
@ -42,17 +44,20 @@ open class BlockchainTime(
}
companion object {
val AVERAGE_BLOCK_TIME = Duration.ofSeconds(DIFFICULTY_TARGET_V2)
val AVERAGE_BLOCK_TIME: Duration = Duration.ofSeconds(DIFFICULTY_TARGET_V2)
fun estimateTimestamp(targetHeight: Int, referencePoint: BlockchainTime): Instant {
require(targetHeight >= 0) { "Block height $targetHeight must not be negative" }
require(targetHeight >= 0) {
"Block height $targetHeight must not be negative"
}
return if (targetHeight == 0) {
Genesis.timestamp
} else {
val heightDiff = targetHeight - referencePoint.height
val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong())
referencePoint.timestamp.plus(estTimeDiff)
return when (targetHeight) {
0 -> Genesis.timestamp
else -> {
val heightDiff = targetHeight - referencePoint.height
val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong())
referencePoint.timestamp.plus(estTimeDiff)
}
}
}
@ -86,6 +91,10 @@ open class BlockchainTime(
operator fun minus(other: BlockchainTime): BlockchainTimeSpan = other.until(this)
}
fun max(a: BlockchainTime, b: BlockchainTime) = if (a >= b) a else b
fun min(a: BlockchainTime, b: BlockchainTime) = if (a <= b) a else b
data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) {
companion object {
val ZERO = BlockchainTimeSpan(duration = Duration.ZERO, blocks = 0)

View File

@ -1,15 +1,25 @@
package im.molly.monero
data class Enote(
class Enote(
val amount: MoneroAmount,
val owner: AccountAddress,
val key: PublicKey,
val key: PublicKey?,
val keyImage: HashDigest?,
val age: Int,
val sourceTxId: String?,
) {
var spent: Boolean = false
init {
require(age >= 0) { "Enote age $age must not be negative" }
}
var spent: Boolean = false
private set
fun markAsSpent() {
spent = true
}
override fun hashCode() = System.identityHashCode(this)
override fun equals(other: Any?) = this === other
}

View File

@ -4,18 +4,21 @@ data class Transaction(
val hash: HashDigest,
// TODO: val version: ProtocolInfo,
val state: TxState,
val timeLock: BlockchainTime,
val timeLock: BlockchainTime?,
val sent: Set<Enote>,
val received: Set<Enote>,
val payments: List<PaymentDetail>,
val fee: MoneroAmount,
val change: MoneroAmount,
) {
val txId: String get() = hash.toString()
val txId: String
get() = hash.toString()
val blockHeight: Int?
get() = (state as? TxState.OnChain)?.blockHeader?.height
}
sealed interface TxState {
data class OnChain(
val blockHeader: BlockHeader,
) : TxState

View File

@ -2,18 +2,20 @@ package im.molly.monero.internal
import android.os.Parcelable
import im.molly.monero.AccountAddress
import im.molly.monero.MoneroAmount
import im.molly.monero.BlockHeader
import im.molly.monero.BlockchainTime
import im.molly.monero.CalledByNative
import im.molly.monero.Enote
import im.molly.monero.HashDigest
import im.molly.monero.MoneroAmount
import im.molly.monero.PaymentDetail
import im.molly.monero.PublicAddress
import im.molly.monero.PublicKey
import im.molly.monero.TimeLocked
import im.molly.monero.Transaction
import im.molly.monero.TxState
import im.molly.monero.internal.constants.CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE
import im.molly.monero.max
import kotlinx.parcelize.Parcelize
/**
@ -27,7 +29,7 @@ import kotlinx.parcelize.Parcelize
internal data class TxInfo
@CalledByNative("wallet.cc") constructor(
val txHash: String,
val key: String,
val publicKey: String?,
val keyImage: String?,
val subAddressMajor: Int,
val subAddressMinor: Int,
@ -61,85 +63,87 @@ internal data class TxInfo
internal fun List<TxInfo>.consolidateTransactions(
blockchainContext: BlockchainTime,
): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> {
val (enoteByKey, enoteByKeyImage) = extractEnotesFromIncomingTxs(blockchainContext)
// Extract enotes from incoming transactions
val allEnotes = filter { it.incoming }.map { it.toEnote(blockchainContext.height) }
val timeLockedEnotes = HashSet<TimeLocked<Enote>>(enoteByKey.size)
val enoteByTxId = allEnotes.groupBy { enote -> enote.sourceTxId!! }
// Group transactions by their hash and then map each group to a Transaction
val groupedByTxId = groupBy { it.txHash }
val enoteByKeyImage = allEnotes.mapNotNull { enote ->
enote.keyImage?.let { keyImage -> keyImage.toString() to enote }
}.toMap()
val validEnotes = HashSet<TimeLocked<Enote>>(allEnotes.size)
// Group transaction info by their hash and then map each group to a Transaction
val groupedByTxId = groupBy { txInfo -> txInfo.txHash }
val txById = groupedByTxId.mapValues { (_, infoList) ->
createTransaction(blockchainContext, infoList, enoteByKey, enoteByKeyImage)
.also { tx ->
if (tx.state !is TxState.Failed) {
val lockedEnotesToAdd =
tx.received.map { enote -> TimeLocked(enote, tx.timeLock) }
timeLockedEnotes.addAll(lockedEnotesToAdd)
tx.sent.forEach { enote -> enote.spent = true }
}
}
}
val tx = infoList.createTransaction(blockchainContext, enoteByTxId, enoteByKeyImage)
return txById to timeLockedEnotes
}
// If transaction isn't failed, calculate unlock time and save enotes
if (tx.state !is TxState.Failed) {
val defaultUnlockTime = BlockchainTime.Block(
height = tx.blockHeight!! + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE,
referencePoint = blockchainContext,
)
val maxUnlockTime = max(defaultUnlockTime, tx.timeLock ?: BlockchainTime.Genesis)
private fun List<TxInfo>.extractEnotesFromIncomingTxs(
blockchainContext: BlockchainTime,
): Pair<Map<String, Enote>, Map<String, Enote>> {
val enoteByKey = mutableMapOf<String, Enote>()
val enoteByKeyImage = mutableMapOf<String, Enote>()
for (txInfo in filter { it.incoming }) {
enoteByKey.computeIfAbsent(txInfo.key) {
val enote = txInfo.toEnote(blockchainContext.height)
txInfo.keyImage?.let { keyImage ->
enoteByKeyImage[keyImage] = enote
}
enote
val lockedEnotesToAdd = tx.received.map { enote -> TimeLocked(enote, maxUnlockTime) }
validEnotes.addAll(lockedEnotesToAdd)
}
// Mark the sent enotes as spent
tx.sent.forEach { enote -> enote.markAsSpent() }
tx
}
return enoteByKey to enoteByKeyImage
return txById to validEnotes
}
private fun createTransaction(
private fun List<TxInfo>.createTransaction(
blockchainContext: BlockchainTime,
infoList: List<TxInfo>,
enoteMap: Map<String, Enote>,
keyImageMap: Map<String, Enote>,
enoteByTxId: Map<String, List<Enote>>,
enoteByKeyImage: Map<String, Enote>,
): Transaction {
val txHash = infoList.first().txHash
val unlockTime = infoList.maxOf { it.unlockTime }
val fee = infoList.maxOf { it.fee }
val change = infoList.maxOf { it.change }
val txHash = first().txHash
val (ins, outs) = infoList.partition { it.incoming }
val fee = maxOf { it.fee }
val change = maxOf { it.change }
val receivedEnotes = ins.map { enoteMap.getValue(it.key) }
val spentKeyImages = outs.mapNotNull { it.keyImage }.toSet()
val sentEnotes = keyImageMap.filterKeys { spentKeyImages.contains(it) }.values
val payments = outs.map { it.toPaymentDetail() }
val timeLock = maxOf { it.unlockTime }.let { unlockTime ->
if (unlockTime == 0L) null else blockchainContext.resolveUnlockTime(unlockTime)
}
val receivedEnotes = enoteByTxId.getValue(txHash).toSet()
val outTxs = filter { !it.incoming }
val spentKeyImages = outTxs.mapNotNull { it.keyImage }
val sentEnotes = enoteByKeyImage.filterKeys { ki -> ki in spentKeyImages }.values.toSet()
val payments = outTxs.map { it.toPaymentDetail() }
return Transaction(
hash = HashDigest(txHash),
state = determineTxState(infoList),
timeLock = blockchainContext.resolveUnlockTime(unlockTime),
sent = sentEnotes.toSet(),
received = receivedEnotes.toSet(),
state = determineTxState(),
timeLock = timeLock,
sent = sentEnotes,
received = receivedEnotes,
payments = payments,
fee = MoneroAmount(fee),
change = MoneroAmount(change),
fee = MoneroAmount(atomicUnits = fee),
change = MoneroAmount(atomicUnits = change),
)
}
private fun determineTxState(infoList: List<TxInfo>): TxState {
val txInfo = infoList.distinctBy { it.state }.single()
private fun List<TxInfo>.determineTxState(): TxState {
val uniqueTx = distinctBy { it.state }.single()
return when (txInfo.state) {
return when (uniqueTx.state) {
TxInfo.OFF_CHAIN -> TxState.OffChain
TxInfo.PENDING -> TxState.InMemoryPool
TxInfo.FAILED -> TxState.Failed
TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(txInfo.height, txInfo.timestamp))
else -> error("Invalid tx state value: ${txInfo.state}")
TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(uniqueTx.height, uniqueTx.timestamp))
else -> error("Invalid tx state value: ${uniqueTx.state}")
}
}
@ -153,15 +157,16 @@ private fun TxInfo.toEnote(blockchainHeight: Int): Enote {
val calculatedAge = if (height == 0) 0 else blockchainHeight - height + 1
return Enote(
amount = MoneroAmount(amount),
amount = MoneroAmount(atomicUnits = amount),
owner = ownerAddress,
key = PublicKey(key),
key = publicKey?.let { PublicKey(it) },
keyImage = keyImage?.let { HashDigest(it) },
age = calculatedAge,
sourceTxId = txHash,
)
}
private fun TxInfo.toPaymentDetail() = PaymentDetail(
amount = MoneroAmount(amount),
amount = MoneroAmount(atomicUnits = amount),
recipient = PublicAddress.parse(recipient!!),
)