From ada39f13e84207e05644523a2bad34778793aed3 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Sun, 3 Mar 2024 12:35:53 +0100 Subject: [PATCH] lib: add per-account balance and account listing to Ledger --- .../im/molly/monero/IBalanceListener.aidl | 2 +- .../main/aidl/im/molly/monero/IWallet.aidl | 1 + .../im/molly/monero/IWalletCallbacks.aidl | 3 +- lib/android/src/main/cpp/wallet/wallet.cc | 14 ++- lib/android/src/main/cpp/wallet/wallet.h | 2 +- .../kotlin/im/molly/monero/AccountAddress.kt | 7 -- .../src/main/kotlin/im/molly/monero/Ledger.kt | 26 ++-- .../kotlin/im/molly/monero/MoneroWallet.kt | 115 ++++++++++++------ .../kotlin/im/molly/monero/PublicAddress.kt | 7 +- .../kotlin/im/molly/monero/WalletAccount.kt | 15 +++ .../kotlin/im/molly/monero/WalletNative.kt | 47 +++++-- .../kotlin/im/molly/monero/internal/TxInfo.kt | 14 +-- 12 files changed, 161 insertions(+), 92 deletions(-) create mode 100644 lib/android/src/main/kotlin/im/molly/monero/WalletAccount.kt diff --git a/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl b/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl index e22e95f..046100b 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl @@ -6,5 +6,5 @@ import im.molly.monero.internal.TxInfo; oneway interface IBalanceListener { void onBalanceChanged(in List txHistory, in String[] subAddresses, in BlockchainTime blockchainTime); void onRefresh(in BlockchainTime blockchainTime); - void onAddressCreated(String subAddress); + void onSubAddressListUpdated(in String[] subAddresses); } 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 7186737..85dd547 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl @@ -13,6 +13,7 @@ interface IWallet { oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); oneway void createAccount(in IWalletCallbacks callback); oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback); + oneway void getAddressesForAccount(int accountIndex, in IWalletCallbacks callback); oneway void getAllAddresses(in IWalletCallbacks callback); oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback); oneway void cancelRefresh(); diff --git a/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl b/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl index 5f167f1..a84a583 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl @@ -3,8 +3,9 @@ package im.molly.monero; import im.molly.monero.BlockchainTime; oneway interface IWalletCallbacks { - void onAddressReady(in String[] subAddresses); void onRefreshResult(in BlockchainTime blockchainTime, int status); void onCommitResult(boolean success); + void onSubAddressReady(String subAddress); + void onSubAddressListReceived(in String[] subAddresses); void onFeesReceived(in long[] fees); } diff --git a/lib/android/src/main/cpp/wallet/wallet.cc b/lib/android/src/main/cpp/wallet/wallet.cc index abfa844..2ac027b 100644 --- a/lib/android/src/main/cpp/wallet/wallet.cc +++ b/lib/android/src/main/cpp/wallet/wallet.cc @@ -220,14 +220,16 @@ std::string Wallet::public_address() const { return account.get_public_address_str(m_wallet.nettype()); } -std::vector Wallet::formatted_subaddresses() { +std::vector Wallet::formatted_subaddresses(uint32_t index_major) { std::lock_guard lock(m_subaddresses_mutex); std::vector ret; ret.reserve(m_subaddresses.size()); for (const auto& entry: m_subaddresses) { - ret.push_back(FormatAccountAddress(entry)); + if (index_major == -1 || index_major == entry.first.major) { + ret.push_back(FormatAccountAddress(entry)); + } } return ret; @@ -745,9 +747,15 @@ JNIEXPORT jobjectArray JNICALL Java_im_molly_monero_WalletNative_nativeGetSubAddresses( JNIEnv* env, jobject thiz, + jint sub_address_major, jlong handle) { auto* wallet = reinterpret_cast(handle); - return NativeToJavaStringArray(env, wallet->formatted_subaddresses()); + try { + auto subaddresses = wallet->formatted_subaddresses(sub_address_major); + return NativeToJavaStringArray(env, subaddresses); + } catch (error::account_index_outofbound& e) { + return NativeToJavaStringArray(env, {}); + } } extern "C" diff --git a/lib/android/src/main/cpp/wallet/wallet.h b/lib/android/src/main/cpp/wallet/wallet.h index 6fb67bf..3702214 100644 --- a/lib/android/src/main/cpp/wallet/wallet.h +++ b/lib/android/src/main/cpp/wallet/wallet.h @@ -112,7 +112,7 @@ class Wallet : i_wallet2_callback { std::vector fetchBaseFeeEstimate(); std::string public_address() const; - std::vector formatted_subaddresses(); + std::vector formatted_subaddresses(uint32_t index_major = -1); uint32_t current_blockchain_height() const { return static_cast(m_last_block_height); } uint64_t current_blockchain_timestamp() const { return m_last_block_timestamp; } diff --git a/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt index 07fab92..9095b1f 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt @@ -49,10 +49,3 @@ data class AccountAddress( override fun toString(): String = "$accountIndex/$subAddressIndex/$publicAddress" } - -fun Iterable.findByIndexes( - accountIndex: Int, - subAddressIndex: Int, -): AccountAddress? { - return find { it.accountIndex == accountIndex && it.subAddressIndex == subAddressIndex } -} 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 8d6767e..775e21a 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt @@ -1,29 +1,17 @@ package im.molly.monero -//import im.molly.monero.proto.LedgerProto - data class Ledger( val publicAddress: PublicAddress, - val accountAddresses: Set, + val indexedAccounts: List, val transactionById: Map, - val enotes: Set>, + val enoteSet: Set>, val checkedAt: BlockchainTime, ) { - val transactions get() = transactionById.values + val transactions: Collection + get() = transactionById.values - val balance: Balance = enotes.calculateBalance() + fun getBalance(): Balance = enoteSet.calculateBalance() -// companion object { -// fun fromProto(proto: LedgerProto) = Ledger( -// publicAddress = PublicAddress.base58(proto.publicAddress), -// txOuts = proto.ownedTxOutsList.map { OwnedTxOut.fromProto(it) }, -// checkedAtBlockHeight = proto.blockHeight, -// ) -// } -// -// fun proto(): LedgerProto = LedgerProto.newBuilder() -// .setPublicAddress(publicAddress.base58) -// .addAllOwnedTxOuts(txOuts.map { it.proto() }) -// .setBlockHeight(checkedAtBlockHeight) -// .build() + fun getBalanceForAccount(accountIndex: Int): Balance = + enoteSet.calculateBalance { it.accountIndex == accountIndex } } diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt index 462599f..ddd313f 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -27,54 +27,90 @@ class MoneroWallet internal constructor( var dataStore by storageAdapter::dataStore - suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = - suspendCancellableCoroutine { continuation -> - wallet.addDetachedSubAddress( - accountIndex, - subAddressIndex, - object : BaseWalletCallbacks() { - override fun onAddressReady(subAddresses: Array) { - val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) - continuation.resume(accountAddress) {} - } - }) - } +// suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = +// suspendCancellableCoroutine { continuation -> +// wallet.addDetachedSubAddress( +// accountIndex, +// subAddressIndex, +// object : BaseWalletCallbacks() { +// override fun onSubAddressReady(subAddress: String) { +// continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {} +// } +// }) +// } - suspend fun createAccount(): AccountAddress = + suspend fun createAccount(): WalletAccount = suspendCancellableCoroutine { continuation -> wallet.createAccount(object : BaseWalletCallbacks() { - override fun onAddressReady(subAddresses: Array) { - val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) - continuation.resume(accountAddress) {} + override fun onSubAddressReady(subAddress: String) { + val primaryAddress = AccountAddress.parseWithIndexes(subAddress) + continuation.resume( + WalletAccount( + addresses = listOf(primaryAddress), + accountIndex = primaryAddress.accountIndex, + ) + ) {} } }) } + /** + * @throws NoSuchAccountException + */ suspend fun createSubAddressForAccount(accountIndex: Int = 0): AccountAddress = suspendCancellableCoroutine { continuation -> wallet.createSubAddressForAccount(accountIndex, object : BaseWalletCallbacks() { - override fun onAddressReady(subAddresses: Array) { - if (subAddresses.isEmpty()) { - throw NoSuchAccountException(accountIndex) - } - val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) - continuation.resume(accountAddress) {} + override fun onSubAddressReady(subAddress: String) { + continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {} } }) } - suspend fun getAllAddresses(): Set = + /** + * @throws NoSuchAccountException + */ + suspend fun findUnusedSubAddress(accountIndex: Int = 0): AccountAddress? { + val ledger = ledger().first() + val transactions = ledger.transactions + val account = ledger.indexedAccounts.getOrNull(accountIndex) + ?: throw NoSuchAccountException(accountIndex) + + return account.addresses.firstOrNull { !it.isAddressUsed(transactions) } + } + + /** + * @throws NoSuchAccountException + */ + suspend fun getAccount(accountIndex: Int = 0): WalletAccount = + suspendCancellableCoroutine { continuation -> + wallet.getAddressesForAccount(accountIndex, object : BaseWalletCallbacks() { + override fun onSubAddressListReceived(subAddresses: Array) { + val accounts = parseAndAggregateAddresses(subAddresses) + continuation.resume(accounts.single()) {} + } + }) + } + + suspend fun getAllAccounts(): List = suspendCancellableCoroutine { continuation -> wallet.getAllAddresses(object : BaseWalletCallbacks() { - override fun onAddressReady(subAddresses: Array) { - continuation.resume(subAddresses.toAccountAddresses()) {} + override fun onSubAddressListReceived(subAddresses: Array) { + val accounts = parseAndAggregateAddresses(subAddresses) + continuation.resume(accounts) {} } }) } - private fun Array.toAccountAddresses(): Set { - return map { AccountAddress.parseWithIndexes(it) }.toSet() - } + private fun parseAndAggregateAddresses(subAddresses: Array): List = + subAddresses.map { AccountAddress.parseWithIndexes(it) } + .groupBy { it.accountIndex } + .map { (index, addresses) -> + WalletAccount( + addresses = addresses, + accountIndex = index, + ) + } + .sortedBy { it.accountIndex } /** * A [Flow] of ledger changes. @@ -88,16 +124,16 @@ class MoneroWallet internal constructor( subAddresses: Array, blockchainTime: BlockchainTime, ) { - val accountAddresses = subAddresses.toAccountAddresses() + val indexedAccounts = parseAndAggregateAddresses(subAddresses) val (txById, enotes) = txHistory.consolidateTransactions( - accountAddresses = accountAddresses, + accounts = indexedAccounts, blockchainContext = blockchainTime, ) val ledger = Ledger( publicAddress = publicAddress, - accountAddresses = accountAddresses, + indexedAccounts = indexedAccounts, transactionById = txById, - enotes = enotes, + enoteSet = enotes, checkedAt = blockchainTime, ) sendLedger(ledger) @@ -107,11 +143,10 @@ class MoneroWallet internal constructor( sendLedger(lastKnownLedger.copy(checkedAt = blockchainTime)) } - override fun onAddressCreated(subAddress: String) { - val addressSet = lastKnownLedger.accountAddresses.toMutableSet() - val accountAddress = AccountAddress.parseWithIndexes(subAddress) - if (addressSet.add(accountAddress)) { - sendLedger(lastKnownLedger.copy(accountAddresses = addressSet)) + override fun onSubAddressListUpdated(subAddresses: Array) { + val accountsUpdated = parseAndAggregateAddresses(subAddresses) + if (lastKnownLedger.indexedAccounts != accountsUpdated) { + sendLedger(lastKnownLedger.copy(indexedAccounts = accountsUpdated)) } } @@ -205,12 +240,14 @@ class NoSuchAccountException(private val accountIndex: Int) : NoSuchElementExcep } private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() { - override fun onAddressReady(subAddresses: Array) = Unit - override fun onRefreshResult(blockchainTime: BlockchainTime, status: Int) = Unit override fun onCommitResult(success: Boolean) = Unit + override fun onSubAddressReady(subAddress: String) = Unit + + override fun onSubAddressListReceived(subAddresses: Array) = Unit + override fun onFeesReceived(fees: LongArray?) = Unit } 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 d343c29..f770e61 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt @@ -13,6 +13,9 @@ sealed interface PublicAddress : Parcelable { fun isSubAddress(): Boolean companion object { + /** + * @throws InvalidAddress + */ fun parse(addressString: String): PublicAddress { val decoded = try { addressString.decodeBase58() @@ -39,7 +42,9 @@ sealed interface PublicAddress : Parcelable { } } -class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause) +// TODO: Extend ParseException +class InvalidAddress(message: String, cause: Throwable? = null) : + IllegalArgumentException(message, cause) @Parcelize data class StandardAddress( diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletAccount.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletAccount.kt new file mode 100644 index 0000000..84ed0cd --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletAccount.kt @@ -0,0 +1,15 @@ +package im.molly.monero + +data class WalletAccount( + val addresses: List, + val accountIndex: Int, +) + +fun Iterable.findAddressByIndex( + accountIndex: Int, + subAddressIndex: Int = 0, +): AccountAddress? { + return flatMap { it.addresses }.find { + it.accountIndex == accountIndex && it.subAddressIndex == subAddressIndex + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt index 90273be..56469e6 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -122,10 +122,13 @@ internal class WalletNative private constructor( val currentBalance: Balance get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance() - val subAddresses: Array - get() = nativeGetSubAddresses(handle) + private fun getSubAddresses(accountIndex: Int? = null): Array { + return nativeGetSubAddresses(accountIndex ?: -1, handle) + } - private fun txHistorySnapshot(): List = nativeGetTxHistory(handle).toList() + private fun getTxHistorySnapshot(): List { + return nativeGetTxHistory(handle).toList() + } @GuardedBy("listenersLock") private val balanceListeners = mutableSetOf() @@ -224,9 +227,12 @@ internal class WalletNative private constructor( * Also replays the last known balance whenever a new listener registers. */ override fun addBalanceListener(listener: IBalanceListener) { + val txHistory = getTxHistorySnapshot() + val subAddresses = getSubAddresses() + balanceListenersLock.withLock { balanceListeners.add(listener) - listener.onBalanceChanged(txHistorySnapshot(), subAddresses, currentBlockchainTime) + listener.onBalanceChanged(txHistory, subAddresses, currentBlockchainTime) } } @@ -249,8 +255,8 @@ internal class WalletNative private constructor( override fun createAccount(callback: IWalletCallbacks?) { scope.launch(ioDispatcher) { - val subAddress = nativeCreateSubAddressAccount(handle) - notifyAddressCreation(subAddress, callback) + val primaryAddress = nativeCreateSubAddressAccount(handle) + notifyAddressCreation(primaryAddress, callback) } } @@ -260,23 +266,37 @@ internal class WalletNative private constructor( if (subAddress != null) { notifyAddressCreation(subAddress, callback) } else { - callback?.onAddressReady(emptyArray()) + TODO() } } } private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) { balanceListenersLock.withLock { - balanceListeners.forEach { listener -> - listener.onAddressCreated(subAddress) + if (balanceListeners.isNotEmpty()) { + val subAddresses = getSubAddresses() + balanceListeners.forEach { listener -> + listener.onSubAddressListUpdated(subAddresses) + } + } + } + callback?.onSubAddressReady(subAddress) + } + + override fun getAddressesForAccount(accountIndex: Int, callback: IWalletCallbacks) { + scope.launch(ioDispatcher) { + val accountSubAddresses = getSubAddresses(accountIndex) + if (accountSubAddresses.isNotEmpty()) { + callback.onSubAddressListReceived(accountSubAddresses) + } else { + TODO() } } - callback?.onAddressReady(arrayOf(subAddress)) } override fun getAllAddresses(callback: IWalletCallbacks) { scope.launch(ioDispatcher) { - callback.onAddressReady(subAddresses) + callback.onSubAddressListReceived(getSubAddresses()) } } @@ -286,7 +306,8 @@ internal class WalletNative private constructor( if (balanceListeners.isNotEmpty()) { val blockchainTime = network.blockchainTime(height, timestamp) val call = if (balanceChanged) { - val txHistory = txHistorySnapshot() + val txHistory = getTxHistorySnapshot() + val subAddresses = getSubAddresses() fun(listener: IBalanceListener) { listener.onBalanceChanged(txHistory, subAddresses, blockchainTime) } @@ -396,7 +417,7 @@ internal class WalletNative private constructor( private external fun nativeGetPublicAddress(handle: Long): String private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int private external fun nativeGetCurrentBlockchainTimestamp(handle: Long): Long - private external fun nativeGetSubAddresses(handle: Long): Array + private external fun nativeGetSubAddresses(subAddressMajor: Int, handle: Long): Array private external fun nativeGetTxHistory(handle: Long): Array private external fun nativeFetchBaseFeeEstimate(handle: Long): LongArray private external fun nativeLoad(handle: Long, fd: Int): Boolean diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt index 16bfcd2..34dca6b 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt @@ -1,12 +1,12 @@ package im.molly.monero.internal import android.os.Parcelable -import im.molly.monero.AccountAddress import im.molly.monero.BlockHeader import im.molly.monero.BlockchainTime import im.molly.monero.CalledByNative import im.molly.monero.Enote import im.molly.monero.HashDigest +import im.molly.monero.WalletAccount import im.molly.monero.MoneroAmount import im.molly.monero.PaymentDetail import im.molly.monero.PublicAddress @@ -15,7 +15,7 @@ import im.molly.monero.TimeLocked import im.molly.monero.Transaction import im.molly.monero.TxState import im.molly.monero.UnlockTime -import im.molly.monero.findByIndexes +import im.molly.monero.findAddressByIndex import im.molly.monero.isBlockHeightInRange import kotlinx.parcelize.Parcelize import java.time.Instant @@ -62,12 +62,12 @@ internal data class TxInfo @CalledByNative constructor( } internal fun List.consolidateTransactions( - accountAddresses: Set, + accounts: List, blockchainContext: BlockchainTime, ): Pair, Set>> { // Extract enotes from incoming transactions val allEnotes = - filter { it.incoming }.map { it.toEnote(blockchainContext.height, accountAddresses) } + filter { it.incoming }.map { it.toEnote(blockchainContext.height, accounts) } val enoteByTxId = allEnotes.groupBy { enote -> enote.sourceTxId!! } @@ -152,9 +152,9 @@ private fun List.determineTxState(): TxState { } } -private fun TxInfo.toEnote(blockchainHeight: Int, accountAddresses: Set): Enote { - val ownerAddress = accountAddresses.findByIndexes(subAddressMajor, subAddressMinor) - ?: error("Failed to find account address for: $subAddressMajor/$subAddressMinor") +private fun TxInfo.toEnote(blockchainHeight: Int, accounts: List): Enote { + val ownerAddress = accounts.findAddressByIndex(subAddressMajor, subAddressMinor) + ?: error("Failed to find subaddress: $subAddressMajor/$subAddressMinor") val calculatedAge = if (height > 0) (blockchainHeight - height + 1) else 0 return Enote(