lib: refactor balance and associated classes

This commit is contained in:
Oscar Mira 2023-10-18 00:05:07 +02:00
parent 3c3fc3507c
commit 75b33a24f3
13 changed files with 233 additions and 227 deletions

View File

@ -9,15 +9,16 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.Ledger
import im.molly.monero.MoneroCurrency import im.molly.monero.MoneroCurrency
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme
@Composable @Composable
fun WalletRoute( fun WalletRoute(
@ -41,9 +42,9 @@ fun WalletRoute(
@Composable @Composable
private fun WalletScreen( private fun WalletScreen(
uiState: WalletUiState, uiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onWalletConfigChange: (WalletConfig) -> Unit = {},
onBackClick: () -> Unit = {},
) { ) {
when (uiState) { when (uiState) {
WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick) WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick)
@ -110,9 +111,9 @@ private fun WalletScreenPopulated(
Text( Text(
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
text = buildAnnotatedString { text = buildAnnotatedString {
append(MoneroCurrency.symbol + " ") append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { 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,
),
),
)
}
}

View File

@ -57,8 +57,8 @@ class WalletNativeTest {
secretSpendKey = randomSecretKey(), secretSpendKey = randomSecretKey(),
).currentBalance ).currentBalance
) { ) {
assertThat(totalAmount).isEqualTo(0.toAtomicAmount()) assertThat(totalAmount).isEqualTo(0.toAtomicUnits())
assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicAmount()) assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicUnits())
} }
} }
} }

View File

@ -128,8 +128,7 @@ std::string Wallet::public_address() const {
} }
void Wallet::set_current_blockchain_height(uint64_t height) { void Wallet::set_current_blockchain_height(uint64_t height) {
LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached");
"Blockchain max height reached");
m_blockchain_height = height; m_blockchain_height = height;
} }
@ -571,8 +570,8 @@ Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight(
ScopedJvmLocalRef<jobject> nativeToJvmTxInfo(JNIEnv* env, ScopedJvmLocalRef<jobject> nativeToJvmTxInfo(JNIEnv* env,
const TxInfo& info) { const TxInfo& info) {
LOG_FATAL_IF(info.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, LOG_FATAL_IF(info.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached");
"Blockchain max height reached"); // TODO: Check amount overflow
return {env, TxInfoClass.newObject( return {env, TxInfoClass.newObject(
env, TxInfo_ctor, env, TxInfo_ctor,
nativeToJvmString(env, pod_to_hex(info.m_tx_hash)).obj(), nativeToJvmString(env, pod_to_hex(info.m_tx_hash)).obj(),

View File

@ -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 <T> Iterable<T>.sumOf(selector: (T) -> AtomicAmount): AtomicAmount {
var sum: AtomicAmount = AtomicAmount.ZERO
for (element in this) {
sum += selector(element)
}
return sum
}
fun Iterable<AtomicAmount>.sum(): AtomicAmount {
var sum: AtomicAmount = AtomicAmount.ZERO
for (element in this) {
sum += element
}
return sum
}

View File

@ -1,31 +1,38 @@
package im.molly.monero package im.molly.monero
data class Balance( data class Balance(
val pendingBalance: AtomicAmount, val pendingAmount: MoneroAmount,
val timeLockedAmounts: Set<TimeLocked<AtomicAmount>>, val timeLockedAmounts: List<TimeLocked<MoneroAmount>>,
) { ) {
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 timeLockedAmounts
.mapNotNull { it.getValueIfUnlocked(currentTime) } .filter { it.isUnlocked(targetTime) }
.sum() .sumOf { it.value }
fun lockedBalance(currentTime: BlockchainTime): Map<BlockchainTimeSpan, AtomicAmount> = fun lockedAmountsAt(targetTime: BlockchainTime): Map<BlockchainTimeSpan, MoneroAmount> =
timeLockedAmounts timeLockedAmounts
.filter { it.isLocked(currentTime) } .filter { it.isLocked(targetTime) }
.groupBy({ it.timeUntilUnlock(currentTime) }, { it.value }) .groupBy({ it.timeUntilUnlock(targetTime) }, { it.value })
.mapValues { (_, amounts) -> amounts.sum() } .mapValues { (_, amounts) ->
amounts.sum()
}
} }
fun Iterable<TimeLocked<Enote>>.balance(subAccountSelector: (Int) -> Boolean = { true }): Balance { fun Iterable<TimeLocked<Enote>>.calculateBalance(): Balance {
val enotes = filter { subAccountSelector(it.value.owner.accountIndex) } var pendingAmount = MoneroAmount.ZERO
val (pending, confirmed) = enotes.partition { it.value.age == 0 }
val timeLockedSet = confirmed val lockedAmounts = mutableListOf<TimeLocked<MoneroAmount>>()
.groupBy({ it.unlockTime }, { it.value.amount })
.map { (unlockTime, amounts) -> TimeLocked(amounts.sum(), unlockTime) }
.toSet()
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)
} }

View File

@ -27,11 +27,11 @@ open class BlockchainTime(
data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193)) data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193))
class Block(height: Int, currentTime: BlockchainTime = Genesis) : class Block(height: Int, referencePoint: BlockchainTime = Genesis) :
BlockchainTime(height, estimateTimestamp(height, currentTime)) BlockchainTime(height, estimateTimestamp(height, referencePoint)) {}
class Timestamp(timestamp: Instant, currentTime: BlockchainTime = Genesis) : class Timestamp(timestamp: Instant, referencePoint: BlockchainTime = Genesis) :
BlockchainTime(estimateBlockHeight(timestamp, currentTime), timestamp) { BlockchainTime(estimateBlockHeight(timestamp, referencePoint), timestamp) {
constructor(date: LocalDate) : this(Instant.ofEpochSecond(date.toEpochDay())) constructor(date: LocalDate) : this(Instant.ofEpochSecond(date.toEpochDay()))
@ -44,34 +44,35 @@ open class BlockchainTime(
companion object { companion object {
val AVERAGE_BLOCK_TIME = Duration.ofSeconds(DIFFICULTY_TARGET_V2) 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" } require(targetHeight >= 0) { "Block height $targetHeight must not be negative" }
return if (targetHeight == 0) { return if (targetHeight == 0) {
Genesis.timestamp Genesis.timestamp
} else { } else {
val heightDiff = targetHeight - currentTime.height val heightDiff = targetHeight - referencePoint.height
val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong()) val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong())
currentTime.timestamp.plus(estTimeDiff) referencePoint.timestamp.plus(estTimeDiff)
} }
} }
fun estimateBlockHeight(targetTime: Instant, currentTime: BlockchainTime): Int { fun estimateBlockHeight(targetTime: Instant, referencePoint: BlockchainTime): Int {
val timeDiff = Duration.between(currentTime.timestamp, targetTime) val timeDiff = Duration.between(referencePoint.timestamp, targetTime)
val estHeight = timeDiff.seconds / AVERAGE_BLOCK_TIME.seconds + currentTime.height val estHeight = timeDiff.seconds / AVERAGE_BLOCK_TIME.seconds + referencePoint.height
val clampedHeight = estHeight.coerceIn(0, BlockHeader.MAX_HEIGHT.toLong()) val clampedHeight = estHeight.coerceIn(0, BlockHeader.MAX_HEIGHT.toLong())
return clampedHeight.toInt() return clampedHeight.toInt()
} }
} }
fun fromUnlockTime(heightOrTimestamp: Long): BlockchainTime { fun resolveUnlockTime(heightOrTimestamp: Long): BlockchainTime {
return if (isBlockHeightInRange(heightOrTimestamp)) { return if (isBlockHeightInRange(heightOrTimestamp)) {
Block(heightOrTimestamp.toInt(), currentTime = this) val height = heightOrTimestamp.toInt()
Block(height, referencePoint = this)
} else { } else {
val clampedTs = val clampedTs =
if (heightOrTimestamp < 0 || heightOrTimestamp > Instant.MAX.epochSecond) Instant.MAX if (heightOrTimestamp < 0 || heightOrTimestamp > Instant.MAX.epochSecond) Instant.MAX
else Instant.ofEpochSecond(heightOrTimestamp) 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) { data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) {
@ -94,9 +95,7 @@ data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) {
class TimeLocked<T>(val value: T, val unlockTime: BlockchainTime) { class TimeLocked<T>(val value: T, val unlockTime: BlockchainTime) {
fun isLocked(currentTime: BlockchainTime): Boolean = currentTime < unlockTime fun isLocked(currentTime: BlockchainTime): Boolean = currentTime < unlockTime
fun getValueIfUnlocked(currentTime: BlockchainTime): T? { fun isUnlocked(currentTime: BlockchainTime) = !isLocked(currentTime)
return if (isLocked(currentTime)) null else value
}
fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan { fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan {
return if (isLocked(currentTime)) { return if (isLocked(currentTime)) {

View File

@ -1,13 +1,14 @@
package im.molly.monero package im.molly.monero
data class Enote( data class Enote(
val amount: AtomicAmount, val amount: MoneroAmount,
val owner: AccountAddress, val owner: AccountAddress,
val key: PublicKey, val key: PublicKey,
val keyImage: HashDigest?, val keyImage: HashDigest?,
val emissionTxId: String?,
val age: Int, val age: Int,
) { ) {
var spent: Boolean = false
init { init {
require(age >= 0) { "Enote age $age must not be negative" } require(age >= 0) { "Enote age $age must not be negative" }
} }

View File

@ -4,11 +4,11 @@ package im.molly.monero
data class Ledger( data class Ledger(
val publicAddress: String, val publicAddress: String,
val txs: Map<String, Transaction>, val transactions: Map<String, Transaction>,
val spendableEnotes: Set<TimeLocked<Enote>>, val enotes: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime, val checkedAt: BlockchainTime,
) { ) {
val balance = spendableEnotes.balance() val balance: Balance = enotes.calculateBalance()
// companion object { // companion object {
// fun fromProto(proto: LedgerProto) = Ledger( // fun fromProto(proto: LedgerProto) = Ledger(

View File

@ -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 <T> Iterable<T>.sumOf(selector: (T) -> MoneroAmount): MoneroAmount {
var sum: MoneroAmount = MoneroAmount.ZERO
for (element in this) {
sum += selector(element)
}
return sum
}
fun Iterable<MoneroAmount>.sum(): MoneroAmount {
var sum: MoneroAmount = MoneroAmount.ZERO
for (element in this) {
sum += element
}
return sum
}

View File

@ -1,21 +1,33 @@
package im.molly.monero package im.molly.monero
import im.molly.monero.internal.constants.CRYPTONOTE_DISPLAY_DECIMAL_POINT
import java.math.BigDecimal
import java.text.NumberFormat import java.text.NumberFormat
import java.util.* import java.util.Locale
object MoneroCurrency { object MoneroCurrency {
const val symbol = "XMR" const val SYMBOL = "XMR"
fun format(atomicAmount: AtomicAmount, formatter: NumberFormat = DefaultFormatter): String = const val MAX_PRECISION = MoneroAmount.ATOMIC_UNIT_SCALE
formatter.format(BigDecimal.valueOf(atomicAmount.value, CRYPTONOTE_DISPLAY_DECIMAL_POINT))
fun parse(source: String, formatter: NumberFormat = DefaultFormatter): AtomicAmount { 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() TODO()
} }
} }
val DefaultFormatter: NumberFormat = NumberFormat.getInstance(Locale.US).apply {
minimumFractionDigits = 5
} }

View File

@ -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()
}

View File

@ -8,22 +8,13 @@ data class Transaction(
val sent: Set<Enote>, val sent: Set<Enote>,
val received: Set<Enote>, val received: Set<Enote>,
val payments: List<PaymentDetail>, val payments: List<PaymentDetail>,
val fee: AtomicAmount, val fee: MoneroAmount,
val change: AtomicAmount, val change: MoneroAmount,
) { ) {
val txId: String get() = hash.toString() 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 { sealed interface TxState {
val confirmed get() = this is OnChain
data class OnChain( data class OnChain(
val blockHeader: BlockHeader, val blockHeader: BlockHeader,
@ -39,6 +30,6 @@ sealed interface TxState {
} }
data class PaymentDetail( data class PaymentDetail(
val amount: AtomicAmount, val amount: MoneroAmount,
val recipient: PublicAddress, val recipient: PublicAddress,
) )

View File

@ -2,7 +2,7 @@ package im.molly.monero.internal
import android.os.Parcelable import android.os.Parcelable
import im.molly.monero.AccountAddress import im.molly.monero.AccountAddress
import im.molly.monero.AtomicAmount import im.molly.monero.MoneroAmount
import im.molly.monero.BlockHeader import im.molly.monero.BlockHeader
import im.molly.monero.BlockchainTime import im.molly.monero.BlockchainTime
import im.molly.monero.CalledByNative import im.molly.monero.CalledByNative
@ -43,8 +43,6 @@ internal data class TxInfo
val incoming: Boolean, val incoming: Boolean,
) : Parcelable { ) : Parcelable {
val outgoing get() = !incoming
companion object State { companion object State {
const val OFF_CHAIN: Int = 0 const val OFF_CHAIN: Int = 0
const val PENDING: Int = 1 const val PENDING: Int = 1
@ -61,93 +59,109 @@ internal data class TxInfo
} }
internal fun List<TxInfo>.consolidateTransactions( internal fun List<TxInfo>.consolidateTransactions(
blockchainTime: BlockchainTime, blockchainContext: BlockchainTime,
): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> { ): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> {
val enoteMap = mutableMapOf<String, Enote>() val (enoteByKey, enoteByKeyImage) = extractEnotesFromIncomingTxs(blockchainContext)
val keyImageMap = mutableMapOf<String, Enote>()
val spentSet = mutableSetOf<String>()
forEach { txInfo -> val timeLockedEnotes = HashSet<TimeLocked<Enote>>(enoteByKey.size)
if (txInfo.incoming) {
enoteMap.computeIfAbsent(txInfo.key) { // Group transactions by their hash and then map each group to a Transaction
txInfo.toEnote(blockchainTime.height).also { enote -> val groupedByTxId = groupBy { it.txHash }
txInfo.keyImage?.let { keyImageMap[it] = enote } 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)
}
} }
val groupedByTxHash = groupBy { it.txHash } return txById to timeLockedEnotes
val txs = groupedByTxHash.mapValues { (txHash, infoList) -> }
createTransaction(txHash, infoList, enoteMap, keyImageMap, blockchainTime)
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 spendableEnotes = enoteMap return enoteByKey to enoteByKeyImage
.filterKeys { !spentSet.contains(it) }
.map { (_, enote) ->
TimeLocked(enote, txs[enote.emissionTxId]!!.timeLock)
}
.toSet()
return txs to spendableEnotes
} }
private fun createTransaction( private fun createTransaction(
txHash: String, blockchainContext: BlockchainTime,
infoList: List<TxInfo>, infoList: List<TxInfo>,
enoteMap: Map<String, Enote>, enoteMap: Map<String, Enote>,
keyImageMap: Map<String, Enote>, keyImageMap: Map<String, Enote>,
blockchainTime: BlockchainTime,
): Transaction { ): Transaction {
val txHash = infoList.first().txHash
val unlockTime = infoList.maxOf { it.unlockTime } val unlockTime = infoList.maxOf { it.unlockTime }
val fee = infoList.maxOf { it.fee } val fee = infoList.maxOf { it.fee }
val change = infoList.maxOf { it.change } val change = infoList.maxOf { it.change }
val (ins, outs) = infoList.partition { it.incoming } 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 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() } val payments = outs.map { it.toPaymentDetail() }
return Transaction( return Transaction(
hash = HashDigest(txHash), hash = HashDigest(txHash),
state = determineTxState(infoList), state = determineTxState(infoList),
timeLock = blockchainTime.fromUnlockTime(unlockTime), timeLock = blockchainContext.resolveUnlockTime(unlockTime),
sent = sent.toSet(), sent = sentEnotes.toSet(),
received = received.toSet(), received = receivedEnotes.toSet(),
payments = payments, payments = payments,
fee = AtomicAmount(fee), fee = MoneroAmount(fee),
change = AtomicAmount(change), change = MoneroAmount(change),
) )
} }
private fun determineTxState(infoList: List<TxInfo>): TxState { private fun determineTxState(infoList: List<TxInfo>): TxState {
val txInfo = infoList.distinctBy { it.state }.single() val txInfo = infoList.distinctBy { it.state }.single()
return when (txInfo.state) { return when (txInfo.state) {
TxInfo.OFF_CHAIN -> TxState.OffChain TxInfo.OFF_CHAIN -> TxState.OffChain
TxInfo.PENDING -> TxState.InMemoryPool TxInfo.PENDING -> TxState.InMemoryPool
TxInfo.FAILED -> TxState.Failed TxInfo.FAILED -> TxState.Failed
TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(txInfo.height, txInfo.timestamp)) 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( private fun TxInfo.toEnote(blockchainHeight: Int): Enote {
amount = AtomicAmount(amount), val ownerAddress = AccountAddress(
owner = AccountAddress(
publicAddress = PublicAddress.parse(recipient!!), publicAddress = PublicAddress.parse(recipient!!),
accountIndex = subAddressMajor, accountIndex = subAddressMajor,
subAddressIndex = subAddressMinor, subAddressIndex = subAddressMinor
), )
val calculatedAge = if (height == 0) 0 else blockchainHeight - height + 1
return Enote(
amount = MoneroAmount(amount),
owner = ownerAddress,
key = PublicKey(key), key = PublicKey(key),
keyImage = keyImage?.let { HashDigest(it) }, keyImage = keyImage?.let { HashDigest(it) },
emissionTxId = txHash, age = calculatedAge,
age = if (height == 0) 0 else (blockchainHeight - height + 1) )
) }
private fun TxInfo.toPaymentDetail() = PaymentDetail( private fun TxInfo.toPaymentDetail() = PaymentDetail(
amount = AtomicAmount(amount), amount = MoneroAmount(amount),
recipient = PublicAddress.parse(recipient!!), recipient = PublicAddress.parse(recipient!!),
) )