From 75b33a24f33024b231af7550c220b7166a40e39e Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Wed, 18 Oct 2023 00:05:07 +0200 Subject: [PATCH] lib: refactor balance and associated classes --- .../im/molly/monero/demo/ui/WalletScreen.kt | 37 +++++- .../im/molly/monero/WalletNativeTest.kt | 4 +- lib/android/src/main/cpp/wallet.cc | 7 +- .../kotlin/im/molly/monero/AtomicAmount.kt | 40 ------- .../main/kotlin/im/molly/monero/Balance.kt | 43 ++++--- .../kotlin/im/molly/monero/BlockchainTime.kt | 33 +++--- .../src/main/kotlin/im/molly/monero/Enote.kt | 5 +- .../src/main/kotlin/im/molly/monero/Ledger.kt | 6 +- .../kotlin/im/molly/monero/MoneroAmount.kt | 61 ++++++++++ .../kotlin/im/molly/monero/MoneroCurrency.kt | 36 ++++-- .../main/kotlin/im/molly/monero/OwnedTxOut.kt | 63 ---------- .../kotlin/im/molly/monero/Transaction.kt | 15 +-- .../kotlin/im/molly/monero/internal/TxInfo.kt | 110 ++++++++++-------- 13 files changed, 233 insertions(+), 227 deletions(-) delete mode 100644 lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt delete mode 100644 lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt index da51f67..85675eb 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt @@ -9,15 +9,16 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import im.molly.monero.Balance +import im.molly.monero.BlockchainTime import im.molly.monero.Ledger import im.molly.monero.MoneroCurrency import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.theme.AppIcons +import im.molly.monero.demo.ui.theme.AppTheme @Composable fun WalletRoute( @@ -41,9 +42,9 @@ fun WalletRoute( @Composable private fun WalletScreen( uiState: WalletUiState, - onWalletConfigChange: (WalletConfig) -> Unit, - onBackClick: () -> Unit, modifier: Modifier = Modifier, + onWalletConfigChange: (WalletConfig) -> Unit = {}, + onBackClick: () -> Unit = {}, ) { when (uiState) { WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick) @@ -110,9 +111,9 @@ private fun WalletScreenPopulated( Text( style = MaterialTheme.typography.headlineLarge, text = buildAnnotatedString { - append(MoneroCurrency.symbol + " ") + append(MoneroCurrency.SYMBOL + " ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(MoneroCurrency.format(ledger.balance.confirmedBalance)) + append(MoneroCurrency.Formatter(precision = 5).format(ledger.balance.confirmedAmount)) } } ) @@ -177,3 +178,27 @@ private fun WalletKebabMenu( ) } } + +@Preview +@Composable +private fun WalletScreenPreview() { + AppTheme { + WalletScreen( + uiState = WalletUiState.Success( + WalletConfig( + id = 0, + publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + filename = "", + name = "Personal", + remoteNodes = emptySet(), + ), + Ledger( + publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", + transactions = emptyMap(), + enotes = emptySet(), + checkedAt = BlockchainTime.Genesis, + ), + ), + ) + } +} diff --git a/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt b/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt index 3a1c68b..438218f 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt @@ -57,8 +57,8 @@ class WalletNativeTest { secretSpendKey = randomSecretKey(), ).currentBalance ) { - assertThat(totalAmount).isEqualTo(0.toAtomicAmount()) - assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicAmount()) + assertThat(totalAmount).isEqualTo(0.toAtomicUnits()) + assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicUnits()) } } } diff --git a/lib/android/src/main/cpp/wallet.cc b/lib/android/src/main/cpp/wallet.cc index 222f160..c93ac40 100644 --- a/lib/android/src/main/cpp/wallet.cc +++ b/lib/android/src/main/cpp/wallet.cc @@ -128,8 +128,7 @@ std::string Wallet::public_address() const { } void Wallet::set_current_blockchain_height(uint64_t height) { - LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, - "Blockchain max height reached"); + LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached"); m_blockchain_height = height; } @@ -571,8 +570,8 @@ 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"); + LOG_FATAL_IF(info.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(), diff --git a/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt b/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt deleted file mode 100644 index 3752757..0000000 --- a/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt +++ /dev/null @@ -1,40 +0,0 @@ -package im.molly.monero - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -// TODO: Rename to MoneroAmount? - -@JvmInline -@Parcelize -value class AtomicAmount(val value: Long) : Parcelable { - operator fun plus(other: AtomicAmount) = AtomicAmount(Math.addExact(this.value, other.value)) - - operator fun minus(other: AtomicAmount) = AtomicAmount(Math.subtractExact(this.value, other.value)) - - operator fun compareTo(other: Int): Int = value.compareTo(other) - - companion object { - val ZERO = AtomicAmount(0) - } -} - -fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this) - -fun Int.toAtomicAmount(): AtomicAmount = AtomicAmount(this.toLong()) - -inline fun Iterable.sumOf(selector: (T) -> AtomicAmount): AtomicAmount { - var sum: AtomicAmount = AtomicAmount.ZERO - for (element in this) { - sum += selector(element) - } - return sum -} - -fun Iterable.sum(): AtomicAmount { - var sum: AtomicAmount = AtomicAmount.ZERO - for (element in this) { - sum += element - } - return sum -} diff --git a/lib/android/src/main/kotlin/im/molly/monero/Balance.kt b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt index 9943d26..c251344 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Balance.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt @@ -1,31 +1,38 @@ package im.molly.monero data class Balance( - val pendingBalance: AtomicAmount, - val timeLockedAmounts: Set>, + val pendingAmount: MoneroAmount, + val timeLockedAmounts: List>, ) { - val confirmedBalance: AtomicAmount = timeLockedAmounts.sumOf { it.value } + val confirmedAmount: MoneroAmount = timeLockedAmounts.sumOf { it.value } + val totalAmount: MoneroAmount = confirmedAmount + pendingAmount - fun unlockedBalance(currentTime: BlockchainTime): AtomicAmount = + fun unlockedAmountAt(targetTime: BlockchainTime): MoneroAmount = timeLockedAmounts - .mapNotNull { it.getValueIfUnlocked(currentTime) } - .sum() + .filter { it.isUnlocked(targetTime) } + .sumOf { it.value } - fun lockedBalance(currentTime: BlockchainTime): Map = + fun lockedAmountsAt(targetTime: BlockchainTime): Map = timeLockedAmounts - .filter { it.isLocked(currentTime) } - .groupBy({ it.timeUntilUnlock(currentTime) }, { it.value }) - .mapValues { (_, amounts) -> amounts.sum() } + .filter { it.isLocked(targetTime) } + .groupBy({ it.timeUntilUnlock(targetTime) }, { it.value }) + .mapValues { (_, amounts) -> + amounts.sum() + } } -fun Iterable>.balance(subAccountSelector: (Int) -> Boolean = { true }): Balance { - val enotes = filter { subAccountSelector(it.value.owner.accountIndex) } - val (pending, confirmed) = enotes.partition { it.value.age == 0 } +fun Iterable>.calculateBalance(): Balance { + var pendingAmount = MoneroAmount.ZERO - val timeLockedSet = confirmed - .groupBy({ it.unlockTime }, { it.value.amount }) - .map { (unlockTime, amounts) -> TimeLocked(amounts.sum(), unlockTime) } - .toSet() + val lockedAmounts = mutableListOf>() - return Balance(pending.sumOf { it.value.amount }, timeLockedSet) + for (timeLocked in filter { it.value.spent }) { + if (timeLocked.value.age == 0) { + pendingAmount += timeLocked.value.amount + } else { + lockedAmounts.add(TimeLocked(timeLocked.value.amount, timeLocked.unlockTime)) + } + } + + return Balance(pendingAmount, lockedAmounts) } 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 5cc3022..69fc47c 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -27,11 +27,11 @@ open class BlockchainTime( data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193)) - class Block(height: Int, currentTime: BlockchainTime = Genesis) : - BlockchainTime(height, estimateTimestamp(height, currentTime)) + class Block(height: Int, referencePoint: BlockchainTime = Genesis) : + BlockchainTime(height, estimateTimestamp(height, referencePoint)) {} - class Timestamp(timestamp: Instant, currentTime: BlockchainTime = Genesis) : - BlockchainTime(estimateBlockHeight(timestamp, currentTime), timestamp) { + class Timestamp(timestamp: Instant, referencePoint: BlockchainTime = Genesis) : + BlockchainTime(estimateBlockHeight(timestamp, referencePoint), timestamp) { constructor(date: LocalDate) : this(Instant.ofEpochSecond(date.toEpochDay())) @@ -44,34 +44,35 @@ open class BlockchainTime( companion object { val AVERAGE_BLOCK_TIME = Duration.ofSeconds(DIFFICULTY_TARGET_V2) - fun estimateTimestamp(targetHeight: Int, currentTime: BlockchainTime): Instant { + fun estimateTimestamp(targetHeight: Int, referencePoint: BlockchainTime): Instant { require(targetHeight >= 0) { "Block height $targetHeight must not be negative" } return if (targetHeight == 0) { Genesis.timestamp } else { - val heightDiff = targetHeight - currentTime.height + val heightDiff = targetHeight - referencePoint.height val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong()) - currentTime.timestamp.plus(estTimeDiff) + referencePoint.timestamp.plus(estTimeDiff) } } - fun estimateBlockHeight(targetTime: Instant, currentTime: BlockchainTime): Int { - val timeDiff = Duration.between(currentTime.timestamp, targetTime) - val estHeight = timeDiff.seconds / AVERAGE_BLOCK_TIME.seconds + currentTime.height + fun estimateBlockHeight(targetTime: Instant, referencePoint: BlockchainTime): Int { + val timeDiff = Duration.between(referencePoint.timestamp, targetTime) + val estHeight = timeDiff.seconds / AVERAGE_BLOCK_TIME.seconds + referencePoint.height val clampedHeight = estHeight.coerceIn(0, BlockHeader.MAX_HEIGHT.toLong()) return clampedHeight.toInt() } } - fun fromUnlockTime(heightOrTimestamp: Long): BlockchainTime { + fun resolveUnlockTime(heightOrTimestamp: Long): BlockchainTime { return if (isBlockHeightInRange(heightOrTimestamp)) { - Block(heightOrTimestamp.toInt(), currentTime = this) + val height = heightOrTimestamp.toInt() + Block(height, referencePoint = this) } else { val clampedTs = if (heightOrTimestamp < 0 || heightOrTimestamp > Instant.MAX.epochSecond) Instant.MAX else Instant.ofEpochSecond(heightOrTimestamp) - Timestamp(clampedTs, currentTime = this) + Timestamp(clampedTs, referencePoint = this) } } @@ -82,7 +83,7 @@ open class BlockchainTime( ) } - operator fun minus(other: BlockchainTime): BlockchainTimeSpan = until(other) + operator fun minus(other: BlockchainTime): BlockchainTimeSpan = other.until(this) } data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) { @@ -94,9 +95,7 @@ data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) { class TimeLocked(val value: T, val unlockTime: BlockchainTime) { fun isLocked(currentTime: BlockchainTime): Boolean = currentTime < unlockTime - fun getValueIfUnlocked(currentTime: BlockchainTime): T? { - return if (isLocked(currentTime)) null else value - } + fun isUnlocked(currentTime: BlockchainTime) = !isLocked(currentTime) fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan { return if (isLocked(currentTime)) { 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 0817cad..6048512 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Enote.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Enote.kt @@ -1,13 +1,14 @@ package im.molly.monero data class Enote( - val amount: AtomicAmount, + val amount: MoneroAmount, val owner: AccountAddress, val key: PublicKey, val keyImage: HashDigest?, - val emissionTxId: String?, val age: Int, ) { + var spent: Boolean = false + init { require(age >= 0) { "Enote age $age must not be negative" } } diff --git a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt index 0445a57..fc9f02b 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt @@ -4,11 +4,11 @@ package im.molly.monero data class Ledger( val publicAddress: String, - val txs: Map, - val spendableEnotes: Set>, + val transactions: Map, + val enotes: Set>, val checkedAt: BlockchainTime, ) { - val balance = spendableEnotes.balance() + val balance: Balance = enotes.calculateBalance() // companion object { // fun fromProto(proto: LedgerProto) = Ledger( diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt new file mode 100644 index 0000000..e96a8f6 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt @@ -0,0 +1,61 @@ +package im.molly.monero + +import android.os.Parcelable +import im.molly.monero.internal.constants.CRYPTONOTE_DISPLAY_DECIMAL_POINT +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@JvmInline +@Parcelize +value class MoneroAmount(val atomicUnits: Long) : Parcelable { + + companion object { + const val ATOMIC_UNIT_SCALE: Int = CRYPTONOTE_DISPLAY_DECIMAL_POINT + + val ZERO = MoneroAmount(0) + } + + fun toXmr(): BigDecimal = BigDecimal.valueOf(atomicUnits, ATOMIC_UNIT_SCALE) + + override fun toString() = atomicUnits.toString() + + operator fun plus(other: MoneroAmount) = + MoneroAmount(Math.addExact(this.atomicUnits, other.atomicUnits)) + + operator fun minus(other: MoneroAmount) = + MoneroAmount(Math.subtractExact(this.atomicUnits, other.atomicUnits)) + + operator fun compareTo(other: Int): Int = atomicUnits.compareTo(other) +} + +fun Long.toAtomicUnits(): MoneroAmount = MoneroAmount(this) + +fun Int.toAtomicUnits(): MoneroAmount = MoneroAmount(this.toLong()) + +inline val BigDecimal.xmr: MoneroAmount + get() { + val atomicUnits = times(BigDecimal.TEN.pow(MoneroAmount.ATOMIC_UNIT_SCALE)).toLong() + return MoneroAmount(atomicUnits) + } + +inline val Double.xmr: MoneroAmount get() = BigDecimal(this).xmr + +inline val Long.xmr: MoneroAmount get() = BigDecimal(this).xmr + +inline val Int.xmr: MoneroAmount get() = BigDecimal(this).xmr + +inline fun Iterable.sumOf(selector: (T) -> MoneroAmount): MoneroAmount { + var sum: MoneroAmount = MoneroAmount.ZERO + for (element in this) { + sum += selector(element) + } + return sum +} + +fun Iterable.sum(): MoneroAmount { + var sum: MoneroAmount = MoneroAmount.ZERO + for (element in this) { + sum += element + } + return sum +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt index 8c8646e..219a3df 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt @@ -1,21 +1,33 @@ package im.molly.monero -import im.molly.monero.internal.constants.CRYPTONOTE_DISPLAY_DECIMAL_POINT -import java.math.BigDecimal import java.text.NumberFormat -import java.util.* +import java.util.Locale object MoneroCurrency { - const val symbol = "XMR" + const val SYMBOL = "XMR" - fun format(atomicAmount: AtomicAmount, formatter: NumberFormat = DefaultFormatter): String = - formatter.format(BigDecimal.valueOf(atomicAmount.value, CRYPTONOTE_DISPLAY_DECIMAL_POINT)) + const val MAX_PRECISION = MoneroAmount.ATOMIC_UNIT_SCALE - fun parse(source: String, formatter: NumberFormat = DefaultFormatter): AtomicAmount { - TODO() + val DefaultFormatter = Formatter() + + data class Formatter( + val precision: Int = MAX_PRECISION, + val locale: Locale = Locale.US, + ) { + init { + require(precision in 0..MAX_PRECISION) { + "Precision must be between 0 and $MAX_PRECISION" + } + } + + private val numberFormat = NumberFormat.getInstance(locale).apply { + minimumFractionDigits = precision + } + + fun format(amount: MoneroAmount): String = numberFormat.format(amount.toXmr()) + + fun parse(source: String): MoneroAmount { + TODO() + } } } - -val DefaultFormatter: NumberFormat = NumberFormat.getInstance(Locale.US).apply { - minimumFractionDigits = 5 -} diff --git a/lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt b/lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt deleted file mode 100644 index d762e92..0000000 --- a/lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt +++ /dev/null @@ -1,63 +0,0 @@ -package im.molly.monero - -import android.os.Parcelable -import com.google.protobuf.ByteString -import im.molly.monero.proto.OwnedTxOutProto -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -/** - * Transaction output that belongs to a [WalletNative]. - */ -@Parcelize -data class OwnedTxOut -@CalledByNative("wallet.cc") -constructor( - val txId: ByteArray, - val amount: AtomicAmount, - val blockHeight: Long, - val spentInBlockHeight: Long, -) : Parcelable { - - @IgnoredOnParcel - val spent: Boolean = spentInBlockHeight != 0L - - @IgnoredOnParcel - val notSpent = !spent - - init { - require(notSpent || blockHeight <= spentInBlockHeight) - require(amount >= 0) { "TX amount $amount cannot be negative" } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as OwnedTxOut - - if (!txId.contentEquals(other.txId)) return false - - return true - } - - override fun hashCode(): Int { - return txId.contentHashCode() - } - - companion object { - fun fromProto(proto: OwnedTxOutProto) = OwnedTxOut( - txId = proto.txId.toByteArray(), - amount = proto.amount.toAtomicAmount(), - blockHeight = proto.blockHeight, - spentInBlockHeight = proto.spentHeight, - ) - } - - fun proto(): OwnedTxOutProto = OwnedTxOutProto.newBuilder() - .setTxId(ByteString.copyFrom(txId)) - .setAmount(amount.value) - .setBlockHeight(blockHeight) - .setSpentHeight(spentInBlockHeight) - .build() -} 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 da1c69e..d790d8c 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt @@ -8,22 +8,13 @@ data class Transaction( val sent: Set, val received: Set, val payments: List, - val fee: AtomicAmount, - val change: AtomicAmount, + val fee: MoneroAmount, + val change: MoneroAmount, ) { val txId: String get() = hash.toString() - - val netAmount: AtomicAmount = calculateNetAmount() - - private fun calculateNetAmount(): AtomicAmount { - val receivedSum = received.sumOf { it.amount } - val sentSum = sent.sumOf { it.amount } - return receivedSum - sentSum - } } sealed interface TxState { - val confirmed get() = this is OnChain data class OnChain( val blockHeader: BlockHeader, @@ -39,6 +30,6 @@ sealed interface TxState { } data class PaymentDetail( - val amount: AtomicAmount, + val amount: MoneroAmount, val recipient: PublicAddress, ) 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 67daee6..52428e1 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,7 +2,7 @@ package im.molly.monero.internal import android.os.Parcelable import im.molly.monero.AccountAddress -import im.molly.monero.AtomicAmount +import im.molly.monero.MoneroAmount import im.molly.monero.BlockHeader import im.molly.monero.BlockchainTime import im.molly.monero.CalledByNative @@ -43,8 +43,6 @@ internal data class TxInfo val incoming: Boolean, ) : Parcelable { - val outgoing get() = !incoming - companion object State { const val OFF_CHAIN: Int = 0 const val PENDING: Int = 1 @@ -61,93 +59,109 @@ internal data class TxInfo } internal fun List.consolidateTransactions( - blockchainTime: BlockchainTime, + blockchainContext: BlockchainTime, ): Pair, Set>> { - val enoteMap = mutableMapOf() - val keyImageMap = mutableMapOf() - val spentSet = mutableSetOf() + val (enoteByKey, enoteByKeyImage) = extractEnotesFromIncomingTxs(blockchainContext) - forEach { txInfo -> - if (txInfo.incoming) { - enoteMap.computeIfAbsent(txInfo.key) { - txInfo.toEnote(blockchainTime.height).also { enote -> - txInfo.keyImage?.let { keyImageMap[it] = enote } + val timeLockedEnotes = HashSet>(enoteByKey.size) + + // Group transactions by their hash and then map each group to a Transaction + val groupedByTxId = groupBy { it.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 } } } - } else if (txInfo.keyImage != null) { - spentSet.add(txInfo.key) + } + + return txById to timeLockedEnotes +} + +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 groupedByTxHash = groupBy { it.txHash } - val txs = groupedByTxHash.mapValues { (txHash, infoList) -> - createTransaction(txHash, infoList, enoteMap, keyImageMap, blockchainTime) - } - - val spendableEnotes = enoteMap - .filterKeys { !spentSet.contains(it) } - .map { (_, enote) -> - TimeLocked(enote, txs[enote.emissionTxId]!!.timeLock) - } - .toSet() - - return txs to spendableEnotes + return enoteByKey to enoteByKeyImage } private fun createTransaction( - txHash: String, + blockchainContext: BlockchainTime, infoList: List, enoteMap: Map, keyImageMap: Map, - blockchainTime: BlockchainTime, ): 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 (ins, outs) = infoList.partition { it.incoming } - val received = ins.map { enoteMap.getValue(it.key) } + + val receivedEnotes = ins.map { enoteMap.getValue(it.key) } val spentKeyImages = outs.mapNotNull { it.keyImage }.toSet() - val sent = keyImageMap.filterKeys { it in spentKeyImages }.values + val sentEnotes = keyImageMap.filterKeys { spentKeyImages.contains(it) }.values val payments = outs.map { it.toPaymentDetail() } return Transaction( hash = HashDigest(txHash), state = determineTxState(infoList), - timeLock = blockchainTime.fromUnlockTime(unlockTime), - sent = sent.toSet(), - received = received.toSet(), + timeLock = blockchainContext.resolveUnlockTime(unlockTime), + sent = sentEnotes.toSet(), + received = receivedEnotes.toSet(), payments = payments, - fee = AtomicAmount(fee), - change = AtomicAmount(change), + fee = MoneroAmount(fee), + change = MoneroAmount(change), ) } private fun determineTxState(infoList: List): TxState { val txInfo = infoList.distinctBy { it.state }.single() + return when (txInfo.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 -> throw IllegalArgumentException("Invalid tx state value: ${txInfo.state}") + else -> error("Invalid tx state value: ${txInfo.state}") } } -private fun TxInfo.toEnote(blockchainHeight: Int) = Enote( - amount = AtomicAmount(amount), - owner = AccountAddress( +private fun TxInfo.toEnote(blockchainHeight: Int): Enote { + val ownerAddress = AccountAddress( publicAddress = PublicAddress.parse(recipient!!), accountIndex = subAddressMajor, - subAddressIndex = subAddressMinor, - ), - key = PublicKey(key), - keyImage = keyImage?.let { HashDigest(it) }, - emissionTxId = txHash, - age = if (height == 0) 0 else (blockchainHeight - height + 1) -) + subAddressIndex = subAddressMinor + ) + + val calculatedAge = if (height == 0) 0 else blockchainHeight - height + 1 + + return Enote( + amount = MoneroAmount(amount), + owner = ownerAddress, + key = PublicKey(key), + keyImage = keyImage?.let { HashDigest(it) }, + age = calculatedAge, + ) +} private fun TxInfo.toPaymentDetail() = PaymentDetail( - amount = AtomicAmount(amount), + amount = MoneroAmount(amount), recipient = PublicAddress.parse(recipient!!), )