lib: basic address parser and currency formatter

This commit is contained in:
Oscar Mira 2023-04-20 18:51:50 +02:00
parent 36be1d209a
commit 46d387ae37
10 changed files with 161 additions and 73 deletions

View File

@ -10,21 +10,24 @@ class WalletNativeTest {
@Test @Test
fun keyGenerationIsDeterministic() { fun keyGenerationIsDeterministic() {
assertThat( assertThat(
WalletNative.fullNode(MoneroNetwork.Mainnet.id) { WalletNative.fullNode(
secretSpendKey(SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex())) networkId = MoneroNetwork.Mainnet.id,
}.publicAddress secretSpendKey = SecretKey("d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706".parseHex())
).primaryAccountAddress
).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T") ).isEqualTo("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T")
assertThat( assertThat(
WalletNative.fullNode(MoneroNetwork.Testnet.id) { WalletNative.fullNode(
secretSpendKey(SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".parseHex())) networkId = MoneroNetwork.Testnet.id,
}.publicAddress secretSpendKey = SecretKey("48a35268bc33227eea43ac1ecfd144d51efc023c115c26ca68a01cc6201e9900".parseHex()),
).primaryAccountAddress
).isEqualTo("A1v6gVUcGgGE87c1uFRWB1KfPVik2qLLDJiZT3rhZ8qjF3BGA6oHzeDboD23dH8rFaFFcysyqwF6DBj8WUTBWwEhESB7nZz") ).isEqualTo("A1v6gVUcGgGE87c1uFRWB1KfPVik2qLLDJiZT3rhZ8qjF3BGA6oHzeDboD23dH8rFaFFcysyqwF6DBj8WUTBWwEhESB7nZz")
assertThat( assertThat(
WalletNative.fullNode(MoneroNetwork.Stagenet.id) { WalletNative.fullNode(
secretSpendKey(SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".parseHex())) networkId = MoneroNetwork.Stagenet.id,
}.publicAddress secretSpendKey = SecretKey("561a8d4e121ffca7321a7dc6af79679ceb4cdc8c0dcb0ef588b574586c5fac04".parseHex()),
).primaryAccountAddress
).isEqualTo("54kPaUhYgGNBT72N8Bv2DFMqstLGJCEcWg1EAjwpxABkKL3uBtBLAh4VAPKvhWBdaD4ZpiftA8YWFLAxnWL4aQ9TD4vhY4W") ).isEqualTo("54kPaUhYgGNBT72N8Bv2DFMqstLGJCEcWg1EAjwpxABkKL3uBtBLAh4VAPKvhWBdaD4ZpiftA8YWFLAxnWL4aQ9TD4vhY4W")
} }
@ -32,14 +35,16 @@ class WalletNativeTest {
@Test @Test
fun publicAddressesAreDistinct() { fun publicAddressesAreDistinct() {
val publicAddress = val publicAddress =
WalletNative.fullNode(MoneroNetwork.Mainnet.id) { WalletNative.fullNode(
secretSpendKey(randomSecretKey()) networkId = MoneroNetwork.Mainnet.id,
}.publicAddress secretSpendKey = randomSecretKey(),
).primaryAccountAddress
val anotherPublicAddress = val anotherPublicAddress =
WalletNative.fullNode(MoneroNetwork.Mainnet.id) { WalletNative.fullNode(
secretSpendKey(randomSecretKey()) networkId = MoneroNetwork.Mainnet.id,
}.publicAddress secretSpendKey = randomSecretKey(),
).primaryAccountAddress
assertThat(publicAddress).isNotEqualTo(anotherPublicAddress) assertThat(publicAddress).isNotEqualTo(anotherPublicAddress)
} }
@ -47,9 +52,10 @@ class WalletNativeTest {
@Test @Test
fun atGenesisBalanceIsZero() { fun atGenesisBalanceIsZero() {
with( with(
WalletNative.fullNode(MoneroNetwork.Mainnet.id) { WalletNative.fullNode(
secretSpendKey(randomSecretKey()) networkId = MoneroNetwork.Mainnet.id,
}.currentBalance secretSpendKey = randomSecretKey(),
).currentBalance
) { ) {
assertThat(totalAmount).isEqualTo(0.toAtomicAmount()) assertThat(totalAmount).isEqualTo(0.toAtomicAmount())
assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicAmount()) assertThat(totalAmountUnlockedAt(1)).isEqualTo(0.toAtomicAmount())

View File

@ -2,7 +2,6 @@ package im.molly.monero;
import im.molly.monero.IBalanceListener; import im.molly.monero.IBalanceListener;
import im.molly.monero.IRefreshCallback; import im.molly.monero.IRefreshCallback;
import im.molly.monero.PublicAddress;
interface IWallet { interface IWallet {
String getPrimaryAccountAddress(); String getPrimaryAccountAddress();

View File

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

View File

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

View File

@ -1,14 +1,15 @@
package im.molly.monero package im.molly.monero
data class Balance( data class Balance(
private val uTxOuts: Set<OwnedTxOut>, private val spendableTxOuts: Set<OwnedTxOut>,
) { ) {
val totalAmount: Amount = uTxOuts.sumOf { it.amount } val totalAmount: AtomicAmount = spendableTxOuts.sumOf { it.amount }
fun totalAmountUnlockedAt( fun totalAmountUnlockedAt(
blockHeight: Long, blockHeight: Long,
timestampMillis: Long = System.currentTimeMillis() timestampMillis: Long = System.currentTimeMillis()
): Amount { // TODO: Create Timelock class
): AtomicAmount {
require(blockHeight > 0) require(blockHeight > 0)
require(timestampMillis >= 0) require(timestampMillis >= 0)
TODO() TODO()
@ -16,7 +17,7 @@ data class Balance(
companion object { companion object {
fun of(txOuts: List<OwnedTxOut>) = Balance( fun of(txOuts: List<OwnedTxOut>) = Balance(
uTxOuts = txOuts.filter { it.notSpent }.toSet(), spendableTxOuts = txOuts.filter { it.notSpent }.toSet(),
) )
} }
} }

View File

@ -4,10 +4,10 @@ package im.molly.monero
data class Ledger constructor( data class Ledger constructor(
val publicAddress: String, val publicAddress: String,
val txOuts: List<OwnedTxOut>, val receivedOutputs: List<OwnedTxOut>,
val checkedAtBlockHeight: Long, val checkedAtBlockHeight: Long,
) { ) {
val balance = Balance.of(txOuts) val balance = Balance.of(receivedOutputs)
// companion object { // companion object {
// fun fromProto(proto: LedgerProto) = Ledger( // fun fromProto(proto: LedgerProto) = Ledger(

View File

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

View File

@ -14,13 +14,14 @@ data class OwnedTxOut
@CalledByNative("wallet.cc") @CalledByNative("wallet.cc")
constructor( constructor(
val txId: ByteArray, val txId: ByteArray,
val amount: Amount, val amount: AtomicAmount,
val blockHeight: Long, val blockHeight: Long,
val spentInBlockHeight: Long, val spentInBlockHeight: Long,
) : Parcelable { ) : Parcelable {
init { init {
require(blockHeight <= spentInBlockHeight) require(blockHeight <= spentInBlockHeight)
require(amount >= 0) { "TX amount cannot be negative" }
} }
@IgnoredOnParcel @IgnoredOnParcel
@ -55,7 +56,7 @@ constructor(
fun proto(): OwnedTxOutProto = OwnedTxOutProto.newBuilder() fun proto(): OwnedTxOutProto = OwnedTxOutProto.newBuilder()
.setTxId(ByteString.copyFrom(txId)) .setTxId(ByteString.copyFrom(txId))
.setAmount(amount.atomic) .setAmount(amount.value)
.setBlockHeight(blockHeight) .setBlockHeight(blockHeight)
.setSpentHeight(spentInBlockHeight) .setSpentHeight(spentInBlockHeight)
.build() .build()

View File

@ -1,19 +1,82 @@
package im.molly.monero package im.molly.monero
import android.os.Parcelable import im.molly.monero.util.decodeBase58
import kotlinx.parcelize.Parcelize
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 { companion object {
fun parse(publicAddress: String): PublicAddress { 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,
)
}
}

View File

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