diff --git a/lib/android/src/main/cpp/wallet.cc b/lib/android/src/main/cpp/wallet.cc index c93ac40..e22c852 100644 --- a/lib/android/src/main/cpp/wallet.cc +++ b/lib/android/src/main/cpp/wallet.cc @@ -178,7 +178,8 @@ void Wallet::captureTxHistorySnapshot(std::vector& 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& 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& 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& 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 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(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(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) }; } diff --git a/lib/android/src/main/cpp/wallet.h b/lib/android/src/main/cpp/wallet.h index 62db960..789a21d 100644 --- a/lib/android/src/main/cpp/wallet.h +++ b/lib/android/src/main/cpp/wallet.h @@ -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) {} diff --git a/lib/android/src/main/kotlin/im/molly/monero/Block.kt b/lib/android/src/main/kotlin/im/molly/monero/Block.kt index 3f3f21f..700a7bd 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Block.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Block.kt @@ -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 diff --git a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt index 69fc47c..f98a390 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -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) diff --git a/lib/android/src/main/kotlin/im/molly/monero/Enote.kt b/lib/android/src/main/kotlin/im/molly/monero/Enote.kt index 6048512..0fb7fc6 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Enote.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Enote.kt @@ -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 } diff --git a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt index d790d8c..022dba7 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt @@ -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, val received: Set, val payments: List, 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 diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt index 52428e1..9e1e126 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt @@ -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.consolidateTransactions( blockchainContext: BlockchainTime, ): Pair, Set>> { - val (enoteByKey, enoteByKeyImage) = extractEnotesFromIncomingTxs(blockchainContext) + // Extract enotes from incoming transactions + val allEnotes = filter { it.incoming }.map { it.toEnote(blockchainContext.height) } - val timeLockedEnotes = HashSet>(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>(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.extractEnotesFromIncomingTxs( - blockchainContext: BlockchainTime, -): Pair, Map> { - val enoteByKey = mutableMapOf() - val enoteByKeyImage = mutableMapOf() - - 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.createTransaction( blockchainContext: BlockchainTime, - infoList: List, - enoteMap: Map, - keyImageMap: Map, + enoteByTxId: Map>, + enoteByKeyImage: Map, ): 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): TxState { - val txInfo = infoList.distinctBy { it.state }.single() +private fun List.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!!), )