From 46d387ae37a80b193fae733782817fa005196674 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Thu, 20 Apr 2023 18:51:50 +0200 Subject: [PATCH] lib: basic address parser and currency formatter --- .../im/molly/monero/WalletNativeTest.kt | 42 +++++---- .../main/aidl/im/molly/monero/IWallet.aidl | 1 - .../src/main/kotlin/im/molly/monero/Amount.kt | 35 -------- .../kotlin/im/molly/monero/AtomicAmount.kt | 24 ++++++ .../main/kotlin/im/molly/monero/Balance.kt | 9 +- .../src/main/kotlin/im/molly/monero/Ledger.kt | 4 +- .../kotlin/im/molly/monero/MoneroCurrency.kt | 20 +++++ .../main/kotlin/im/molly/monero/OwnedTxOut.kt | 5 +- .../kotlin/im/molly/monero/PublicAddress.kt | 85 ++++++++++++++++--- .../kotlin/im/molly/monero/util/HexFormat.kt | 9 ++ 10 files changed, 161 insertions(+), 73 deletions(-) delete mode 100644 lib/android/src/main/kotlin/im/molly/monero/Amount.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt 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 c645f71..dd51ed8 100644 --- a/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt +++ b/lib/android/src/androidTest/kotlin/im/molly/monero/WalletNativeTest.kt @@ -10,21 +10,24 @@ class WalletNativeTest { @Test fun keyGenerationIsDeterministic() { assertThat( - WalletNative.fullNode(MoneroNetwork.Mainnet.id) { - secretSpendKey(SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex())) - }.publicAddress + WalletNative.fullNode( + networkId = MoneroNetwork.Mainnet.id, + secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex()) + ).primaryAccountAddress ).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T") assertThat( - WalletNative.fullNode(MoneroNetwork.Testnet.id) { - secretSpendKey(SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".parseHex())) - }.publicAddress + WalletNative.fullNode( + networkId = MoneroNetwork.Testnet.id, + secretSpendKey = SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".parseHex()), + ).primaryAccountAddress ).isEqualTo("A1v6gVUcGgGE87c1uFRWB1KfPVik2qLLDJiZT3rhZ8qjF3BGA6oHzeDboD23dH8rFaFFcysyqwF6DBj8WUTBWwEhESB7nZz") assertThat( - WalletNative.fullNode(MoneroNetwork.Stagenet.id) { - secretSpendKey(SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".parseHex())) - }.publicAddress + WalletNative.fullNode( + networkId = MoneroNetwork.Stagenet.id, + secretSpendKey = SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".parseHex()), + ).primaryAccountAddress ).isEqualTo("54kPaUhYgGNBT72N8Bv2DFMqstLGJCEcWg1EAjwpxABkKL3uBtBLAh4VAPKvhWBdaD4ZpiftA8YWFLAxnWL4aQ9TD4vhY4W") } @@ -32,14 +35,16 @@ class WalletNativeTest { @Test fun publicAddressesAreDistinct() { val publicAddress = - WalletNative.fullNode(MoneroNetwork.Mainnet.id) { - secretSpendKey(randomSecretKey()) - }.publicAddress + WalletNative.fullNode( + networkId = MoneroNetwork.Mainnet.id, + secretSpendKey = randomSecretKey(), + ).primaryAccountAddress val anotherPublicAddress = - WalletNative.fullNode(MoneroNetwork.Mainnet.id) { - secretSpendKey(randomSecretKey()) - }.publicAddress + WalletNative.fullNode( + networkId = MoneroNetwork.Mainnet.id, + secretSpendKey = randomSecretKey(), + ).primaryAccountAddress assertThat(publicAddress).isNotEqualTo(anotherPublicAddress) } @@ -47,9 +52,10 @@ class WalletNativeTest { @Test fun atGenesisBalanceIsZero() { with( - WalletNative.fullNode(MoneroNetwork.Mainnet.id) { - secretSpendKey(randomSecretKey()) - }.currentBalance + WalletNative.fullNode( + networkId = MoneroNetwork.Mainnet.id, + secretSpendKey = randomSecretKey(), + ).currentBalance ) { assertThat(totalAmount).isEqualTo(0.toAtomicAmount()) assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicAmount()) diff --git a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl index b86e79f..befc527 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl @@ -2,7 +2,6 @@ package im.molly.monero; import im.molly.monero.IBalanceListener; import im.molly.monero.IRefreshCallback; -import im.molly.monero.PublicAddress; interface IWallet { String getPrimaryAccountAddress(); diff --git a/lib/android/src/main/kotlin/im/molly/monero/Amount.kt b/lib/android/src/main/kotlin/im/molly/monero/Amount.kt deleted file mode 100644 index f4216e1..0000000 --- a/lib/android/src/main/kotlin/im/molly/monero/Amount.kt +++ /dev/null @@ -1,35 +0,0 @@ -package im.molly.monero - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@JvmInline -@Parcelize -value class Amount private constructor( - val atomic: Long, -) : Parcelable { - - init { - require(atomic >= 0) { "XMR amount cannot be negative" } - } - - companion object { - fun atomic(atomic: Long) = Amount(atomic) - - fun pico(pico: Long) = atomic(pico) - } - - operator fun plus(other: Amount) = Amount(Math.addExact(this.atomic, other.atomic)) -} - -fun Long.toAtomicAmount(): Amount = Amount.atomic(this) - -fun Int.toAtomicAmount(): Amount = Amount.atomic(this.toLong()) - -inline fun Iterable.sumOf(selector: (T) -> Amount): Amount { - var sum: Amount = 0L.toAtomicAmount() - for (element in this) { - sum += selector(element) - } - return sum -} diff --git a/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt b/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt new file mode 100644 index 0000000..8e0d7d5 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt @@ -0,0 +1,24 @@ +package im.molly.monero + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class AtomicAmount(val value: Long) : Parcelable { + operator fun plus(other: AtomicAmount) = AtomicAmount(Math.addExact(this.value, other.value)) + + operator fun compareTo(other: Int): Int = value.compareTo(other) +} + +fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this) + +fun Int.toAtomicAmount(): AtomicAmount = AtomicAmount(this.toLong()) + +inline fun Iterable.sumOf(selector: (T) -> AtomicAmount): AtomicAmount { + var sum: AtomicAmount = 0L.toAtomicAmount() + for (element in this) { + sum += selector(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 09d4ccc..8b6a96f 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Balance.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt @@ -1,14 +1,15 @@ package im.molly.monero data class Balance( - private val uTxOuts: Set, + private val spendableTxOuts: Set, ) { - val totalAmount: Amount = uTxOuts.sumOf { it.amount } + val totalAmount: AtomicAmount = spendableTxOuts.sumOf { it.amount } fun totalAmountUnlockedAt( blockHeight: Long, timestampMillis: Long = System.currentTimeMillis() - ): Amount { + // TODO: Create Timelock class + ): AtomicAmount { require(blockHeight > 0) require(timestampMillis >= 0) TODO() @@ -16,7 +17,7 @@ data class Balance( companion object { fun of(txOuts: List) = Balance( - uTxOuts = txOuts.filter { it.notSpent }.toSet(), + spendableTxOuts = txOuts.filter { it.notSpent }.toSet(), ) } } 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 578c5de..f06c59d 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt @@ -4,10 +4,10 @@ package im.molly.monero data class Ledger constructor( val publicAddress: String, - val txOuts: List, + val receivedOutputs: List, val checkedAtBlockHeight: Long, ) { - val balance = Balance.of(txOuts) + val balance = Balance.of(receivedOutputs) // companion object { // fun fromProto(proto: LedgerProto) = Ledger( diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt new file mode 100644 index 0000000..b23e7ee --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt @@ -0,0 +1,20 @@ +package im.molly.monero + +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.* + +object MoneroCurrency { + const val symbol = "XMR" + + fun format(atomicAmount: AtomicAmount, formatter: NumberFormat = DefaultFormatter): String = + formatter.format(BigDecimal.valueOf(atomicAmount.value, 12)) + + fun parse(source: String, formatter: NumberFormat = DefaultFormatter): AtomicAmount { + 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 index 2591cbc..41f86fd 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/OwnedTxOut.kt @@ -14,13 +14,14 @@ data class OwnedTxOut @CalledByNative("wallet.cc") constructor( val txId: ByteArray, - val amount: Amount, + val amount: AtomicAmount, val blockHeight: Long, val spentInBlockHeight: Long, ) : Parcelable { init { require(blockHeight <= spentInBlockHeight) + require(amount >= 0) { "TX amount cannot be negative" } } @IgnoredOnParcel @@ -55,7 +56,7 @@ constructor( fun proto(): OwnedTxOutProto = OwnedTxOutProto.newBuilder() .setTxId(ByteString.copyFrom(txId)) - .setAmount(amount.atomic) + .setAmount(amount.value) .setBlockHeight(blockHeight) .setSpentHeight(spentInBlockHeight) .build() diff --git a/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt b/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt index 0ef2191..6fe6f51 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt @@ -1,19 +1,82 @@ package im.molly.monero -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import im.molly.monero.util.decodeBase58 + +interface PublicAddress { + val network: MoneroNetwork + val subAddress: Boolean + // viewPublicKey: ByteArray + // spendPublicKey: ByteArray -//@Parcelize -data class PublicAddress( - val network: MoneroNetwork, -// // Address type -// // viewPublicKey: ByteArray -// // spendPublicKey: ByteArray -// // Checksum -) { companion object { fun parse(publicAddress: String): PublicAddress { - return PublicAddress(network = MoneroNetwork.Mainnet) // FIXME + val decoded = try { + publicAddress.decodeBase58() + } catch (t: IllegalArgumentException) { + throw InvalidAddress("Base58 decoding error", t) + } + if (decoded.size <= 4) { + throw InvalidAddress("Address too short") + } + + val prefix = decoded[0].toLong() + + StandardAddress.prefixes[prefix]?.let { network -> + return StandardAddress(network) + } + SubAddress.prefixes[prefix]?.let { network -> + TODO() + } + IntegratedAddress.prefixes[prefix]?.let { network -> + TODO() + } + + throw InvalidAddress("Unrecognized address prefix") } } } + +class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause) + +data class StandardAddress( + override val network: MoneroNetwork, +) : PublicAddress { + override val subAddress = false + + companion object { + val prefixes = mapOf( + 18L to MoneroNetwork.Mainnet, + 53L to MoneroNetwork.Testnet, + 24L to MoneroNetwork.Stagenet, + ) + } +} + +data class SubAddress( + override val network: MoneroNetwork, +) : PublicAddress { + override val subAddress = true + + companion object { + val prefixes = mapOf( + 42L to MoneroNetwork.Mainnet, + 64L to MoneroNetwork.Testnet, + 36L to MoneroNetwork.Stagenet, + ) + } +} + +data class IntegratedAddress( + override val network: MoneroNetwork, + val paymentId: Long, +) : PublicAddress { + override val subAddress = false + + companion object { + val prefixes = mapOf( + 19L to MoneroNetwork.Mainnet, + 54L to MoneroNetwork.Testnet, + 25L to MoneroNetwork.Stagenet, + ) + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt b/lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt new file mode 100644 index 0000000..ff0a357 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt @@ -0,0 +1,9 @@ +package im.molly.monero.util + +fun CharSequence.parseHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + + return ByteArray(length / 2) { + Integer.parseInt(substring(it * 2, (it + 1) * 2), 16).toByte() + } +}