mirror of
https://github.com/mollyim/monero-wallet-sdk.git
synced 2025-01-11 23:49:31 -05:00
lib: refactor balance and associated classes
This commit is contained in:
parent
3c3fc3507c
commit
75b33a24f3
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)) {
|
||||
|
@ -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" }
|
||||
}
|
||||
|
@ -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(
|
||||
|
61
lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt
Normal file
61
lib/android/src/main/kotlin/im/molly/monero/MoneroAmount.kt
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
@ -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,
|
||||
)
|
||||
|
@ -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!!),
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user