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

View File

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

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
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(
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<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(
val publicAddress: String,
val txOuts: List<OwnedTxOut>,
val receivedOutputs: List<OwnedTxOut>,
val checkedAtBlockHeight: Long,
) {
val balance = Balance.of(txOuts)
val balance = Balance.of(receivedOutputs)
// companion object {
// 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")
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()

View File

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

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