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.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,
),
),
)
}
}

View File

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

View File

@ -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<jobject> 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(),

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
data class Balance(
val pendingBalance: AtomicAmount,
val timeLockedAmounts: Set<TimeLocked<AtomicAmount>>,
val pendingAmount: MoneroAmount,
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
.mapNotNull { it.getValueIfUnlocked(currentTime) }
.sum()
.filter { it.isUnlocked(targetTime) }
.sumOf { it.value }
fun lockedBalance(currentTime: BlockchainTime): Map<BlockchainTimeSpan, AtomicAmount> =
fun lockedAmountsAt(targetTime: BlockchainTime): Map<BlockchainTimeSpan, MoneroAmount> =
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<TimeLocked<Enote>>.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<TimeLocked<Enote>>.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<TimeLocked<MoneroAmount>>()
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))
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<T>(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)) {

View File

@ -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" }
}

View File

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

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 received: Set<Enote>,
val payments: List<PaymentDetail>,
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,
)

View File

@ -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<TxInfo>.consolidateTransactions(
blockchainTime: BlockchainTime,
blockchainContext: BlockchainTime,
): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> {
val enoteMap = mutableMapOf<String, Enote>()
val keyImageMap = mutableMapOf<String, Enote>()
val spentSet = mutableSetOf<String>()
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<TimeLocked<Enote>>(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<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 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<TxInfo>,
enoteMap: Map<String, Enote>,
keyImageMap: Map<String, Enote>,
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<TxInfo>): 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!!),
)