From 147989ba3f2e102622ddb2ec31cf0293da48fc28 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Mon, 26 Feb 2024 17:17:06 +0100 Subject: [PATCH] lib: add full subaddress support --- .../monero/demo/data/WalletRepository.kt | 6 +- .../im/molly/monero/demo/ui/WalletScreen.kt | 5 +- .../molly/monero/demo/ui/WalletViewModel.kt | 2 +- .../demo/ui/preview/PreviewParameterData.kt | 11 +- .../im/molly/monero/IBalanceListener.aidl | 3 +- .../main/aidl/im/molly/monero/IWallet.aidl | 6 +- .../im/molly/monero/IWalletCallbacks.aidl | 1 + lib/android/src/main/cpp/wallet/wallet.cc | 136 ++++++++++++++++-- lib/android/src/main/cpp/wallet/wallet.h | 17 ++- .../kotlin/im/molly/monero/AccountAddress.kt | 30 +++- .../main/kotlin/im/molly/monero/Balance.kt | 4 +- .../src/main/kotlin/im/molly/monero/Ledger.kt | 3 +- .../kotlin/im/molly/monero/MoneroWallet.kt | 86 ++++++++++- .../kotlin/im/molly/monero/PendingTransfer.kt | 4 +- .../kotlin/im/molly/monero/PublicAddress.kt | 8 +- .../im/molly/monero/RemoteNodeClient.kt | 2 +- .../kotlin/im/molly/monero/WalletNative.kt | 105 +++++++++++--- .../kotlin/im/molly/monero/internal/TxInfo.kt | 15 +- .../molly/monero/loadbalancer/LoadBalancer.kt | 10 +- vendor/monero | 2 +- 20 files changed, 373 insertions(+), 83 deletions(-) diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt index 0a45e42..dc306c8 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt @@ -41,7 +41,7 @@ class WalletRepository( }, httpClient = httpClient, ) - check(config.publicAddress == wallet.primaryAddress.address) { + check(config.publicAddress == wallet.publicAddress.address) { "primary address mismatch" } wallet @@ -71,7 +71,7 @@ class WalletRepository( val uniqueFilename = UUID.randomUUID().toString() val wallet = moneroSdkClient.createWallet(moneroNetwork, uniqueFilename) val walletId = walletDataSource.createWalletConfig( - publicAddress = wallet.primaryAddress.address, + publicAddress = wallet.publicAddress.address, filename = uniqueFilename, name = name, remoteNodeIds = remoteNodeIds, @@ -94,7 +94,7 @@ class WalletRepository( restorePoint, ) val walletId = walletDataSource.createWalletConfig( - publicAddress = wallet.primaryAddress.address, + publicAddress = wallet.publicAddress.address, filename = uniqueFilename, name = name, remoteNodeIds = remoteNodeIds, diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt index afe1158..58fe2a2 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import im.molly.monero.Ledger import im.molly.monero.MoneroCurrency -import im.molly.monero.PublicAddress import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.ui.component.CopyableText import im.molly.monero.demo.ui.component.Toolbar @@ -279,12 +278,12 @@ private fun WalletScreenPopulated( uiState = WalletUiState.Loaded( config = WalletConfig( id = 0, - publicAddress = ledger.primaryAddress.address, + publicAddress = ledger.publicAddress.address, filename = "", name = "Personal", remoteNodes = emptySet(), ), - network = ledger.primaryAddress.network, + network = ledger.publicAddress.network, balance = ledger.balance, blockchainTime = ledger.checkedAt, transactions = emptyList(), diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt index f3dd1e3..b6568af 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt @@ -67,7 +67,7 @@ private fun walletUiState( ledger.transactions .map { WalletTransaction(config.id, it.value) } .sortedByDescending { it.transaction.blockTimestamp ?: Instant.MAX } - val network = ledger.primaryAddress.network + val network = ledger.publicAddress.network WalletUiState.Loaded(config, network, blockchainTime, balance, transactions) } diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt index 85b8075..4a1cdc1 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt @@ -27,7 +27,9 @@ object PreviewParameterData { hash = HashDigest("e7a60483591378d536792d070f2bf6ccb7d0666df03b57f485ddaf66899a294b"), state = TxState.OnChain(blockHeader), network = network, - timeLock = UnlockTime.Block(BlockchainTime(2999850, Instant.ofEpochSecond(1697792826), network)), + timeLock = UnlockTime.Block( + BlockchainTime(2999850, Instant.ofEpochSecond(1697792826), network) + ), sent = emptySet(), received = emptySet(), payments = listOf(PaymentDetail((0.10).xmr, recipients.first())), @@ -37,9 +39,10 @@ object PreviewParameterData { ) val ledger = Ledger( - primaryAddress = PublicAddress.parse("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T"), - checkedAt = BlockchainTime(blockHeader = blockHeader, network = network), - enotes = emptySet(), + publicAddress = PublicAddress.parse("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T"), + accountAddresses = emptySet(), transactions = transactions.associateBy { it.txId }, + enotes = emptySet(), + checkedAt = BlockchainTime(blockHeader = blockHeader, network = network), ) } 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 075fa1b..e22e95f 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl @@ -4,6 +4,7 @@ import im.molly.monero.BlockchainTime; import im.molly.monero.internal.TxInfo; oneway interface IBalanceListener { - void onBalanceChanged(in List txHistory, in BlockchainTime blockchainTime); + void onBalanceChanged(in List txHistory, in String[] subAddresses, in BlockchainTime blockchainTime); void onRefresh(in BlockchainTime blockchainTime); + void onAddressCreated(String subAddress); } 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 a487dc0..f1f496c 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl @@ -7,9 +7,13 @@ import im.molly.monero.PaymentRequest; import im.molly.monero.SweepRequest; interface IWallet { - String getAccountPrimaryAddress(); + String getPublicAddress(); void addBalanceListener(in IBalanceListener listener); void removeBalanceListener(in IBalanceListener listener); + oneway void getOrCreateAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); + oneway void createAccount(in IWalletCallbacks callback); + oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback); + oneway void getAllAddresses(in IWalletCallbacks callback); oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback); oneway void cancelRefresh(); oneway void setRefreshSince(long heightOrTimestamp); 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 d8fb71a..5f167f1 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl @@ -3,6 +3,7 @@ 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 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 63c924c..a7efda7 100644 --- a/lib/android/src/main/cpp/wallet/wallet.cc +++ b/lib/android/src/main/cpp/wallet/wallet.cc @@ -108,6 +108,7 @@ bool Wallet::parseFrom(std::istream& input) { return false; if (!serialization::serialize(ar, m_wallet)) return false; + updateSubaddressMap(m_subaddresses); captureTxHistorySnapshot(m_tx_history); m_account_ready = true; return true; @@ -126,10 +127,42 @@ bool Wallet::writeTo(std::ostream& output) { }); } -template -void Wallet::withTxHistory(Consumer consumer) { - std::lock_guard lock(m_tx_history_mutex); - consumer(m_tx_history); +std::string FormatAccountAddress( + const std::pair& pair) { + std::stringstream ss; + ss << pair.first.major << "/" << pair.first.minor << "/" << pair.second; + return ss.str(); +} + +std::string Wallet::createSubAddressAccount() { + return suspendRefreshAndRunLocked([&]() { + uint32_t index_major = m_wallet.get_num_subaddress_accounts(); + m_wallet.add_subaddress_account(""); + return addSubaddressInternal({index_major, 0}); + }); +} + +std::string Wallet::createSubAddress(uint32_t index_major) { + return suspendRefreshAndRunLocked([&]() { + uint32_t index_minor = m_wallet.get_num_subaddresses(index_major); + m_wallet.add_subaddress(index_major, ""); + return addSubaddressInternal({index_major, index_minor}); + }); +} + +std::string Wallet::addSubAddress(uint32_t index_major, uint32_t index_minor) { + return suspendRefreshAndRunLocked([&]() { + cryptonote::subaddress_index index = {index_major, index_minor}; + m_wallet.create_one_off_subaddress(index); + return addSubaddressInternal(index); + }); +} + +std::string Wallet::addSubaddressInternal(const cryptonote::subaddress_index& index) { + std::string subaddress = m_wallet.get_subaddress_as_str(index); + std::unique_lock lock(m_subaddresses_mutex); + auto ret = m_subaddresses.insert({index, subaddress}); + return FormatAccountAddress(*ret.first); } std::unique_ptr Wallet::createPayment( @@ -139,6 +172,8 @@ std::unique_ptr Wallet::createPayment( int priority, uint32_t account_index, const std::set& subaddr_indexes) { + std::unique_lock wallet_lock(m_wallet_mutex); + std::vector dsts; dsts.reserve(addresses.size()); @@ -170,6 +205,12 @@ std::unique_ptr Wallet::createPayment( return std::make_unique(ptxs); } +template +void Wallet::withTxHistory(Consumer consumer) { + std::lock_guard lock(m_tx_history_mutex); + consumer(m_tx_history); +} + std::vector Wallet::fetchBaseFeeEstimate() { return m_wallet.get_dynamic_base_fee_scaling_estimate(); } @@ -179,6 +220,19 @@ std::string Wallet::public_address() const { return account.get_public_address_str(m_wallet.nettype()); } +std::vector Wallet::formatted_subaddresses() { + 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)); + } + + return ret; +} + cryptonote::account_base& Wallet::require_account() { LOG_FATAL_IF(!m_account_ready, "Account is not initialized"); return m_wallet.get_account(); @@ -244,7 +298,6 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { recv.m_key_image_known = td.m_key_image_known; recv.m_subaddress_major = td.m_subaddr_index.major; recv.m_subaddress_minor = td.m_subaddr_index.minor; - recv.m_recipient = m_wallet.get_subaddress_as_str(td.m_subaddr_index); recv.m_amount = td.m_amount; recv.m_unlock_time = td.m_tx.unlock_time; @@ -310,7 +363,6 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { // Add pending transfers to our own wallet. snapshot.emplace_back(pair.first, TxInfo::INCOMING); TxInfo& recv = snapshot.back(); - recv.m_recipient = m_wallet.get_subaddress_as_str(*dest_subaddr_idx); recv.m_subaddress_major = (*dest_subaddr_idx).major; recv.m_subaddress_minor = (*dest_subaddr_idx).minor; recv.m_amount = dest.amount; @@ -335,7 +387,6 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { if (utx.m_change > 0) { snapshot.emplace_back(pair.first, TxInfo::INCOMING); TxInfo& change = snapshot.back(); - change.m_recipient = m_wallet.get_subaddress_as_str({utx.m_subaddr_account, 0}); change.m_subaddress_major = utx.m_subaddr_account; change.m_subaddress_minor = 0; // All changes go to 0-th subaddress change.m_amount = utx.m_change; @@ -365,7 +416,6 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { for (uint64_t amount: upd.m_amounts) { snapshot.emplace_back(upd.m_tx_hash, TxInfo::INCOMING); TxInfo& recv = snapshot.back(); - recv.m_recipient = m_wallet.get_subaddress_as_str(upd.m_subaddr_index); recv.m_subaddress_major = upd.m_subaddr_index.major; recv.m_subaddress_minor = upd.m_subaddr_index.minor; recv.m_amount = amount; @@ -379,6 +429,23 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { } } +// Only call this function from the callback thread or during initialization. +void Wallet::updateSubaddressMap(std::map& map) { + uint32_t num_accounts = m_wallet.get_num_subaddress_accounts(); + + for (uint32_t index_major = 0; index_major < num_accounts; ++index_major) { + uint32_t num_subaddresses = m_wallet.get_num_subaddresses(index_major); + + for (uint32_t index_minor = 0; index_minor < num_subaddresses; ++index_minor) { + cryptonote::subaddress_index index = {index_major, index_minor}; + + if (map.find(index) == map.end()) { + map[index] = m_wallet.get_subaddress_as_str(index); + } + } + } +} + void Wallet::handleNewBlock(uint64_t height, uint64_t timestamp) { LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached"); m_last_block_height = height; @@ -396,6 +463,9 @@ void Wallet::handleMoneyEvent(uint64_t at_block_height) { void Wallet::processBalanceChanges(bool refresh_running) { if (m_balance_changed) { + m_subaddresses_mutex.lock(); + updateSubaddressMap(m_subaddresses); + m_subaddresses_mutex.unlock(); m_tx_history_mutex.lock(); captureTxHistorySnapshot(m_tx_history); m_tx_history_mutex.unlock(); @@ -624,7 +694,7 @@ Java_im_molly_monero_WalletNative_nativeSetRefreshSince( extern "C" JNIEXPORT jstring JNICALL -Java_im_molly_monero_WalletNative_nativeGetAccountPrimaryAddress( +Java_im_molly_monero_WalletNative_nativeGetPublicAddress( JNIEnv* env, jobject thiz, jlong handle) { @@ -632,6 +702,54 @@ Java_im_molly_monero_WalletNative_nativeGetAccountPrimaryAddress( return NativeToJavaString(env, wallet->public_address()); } +extern "C" +JNIEXPORT jstring JNICALL +Java_im_molly_monero_WalletNative_nativeAddSubAddress( + JNIEnv* env, + jobject thiz, + jlong handle, + jint sub_address_major, + jint sub_address_minor) { + auto* wallet = reinterpret_cast(handle); + return NativeToJavaString( + env, wallet->addSubAddress(sub_address_major, sub_address_minor)); +} + +extern "C" +JNIEXPORT jstring JNICALL +Java_im_molly_monero_WalletNative_nativeCreateSubAddressAccount( + JNIEnv* env, + jobject thiz, + jlong handle) { + auto* wallet = reinterpret_cast(handle); + return NativeToJavaString(env, wallet->createSubAddressAccount()); +} + +extern "C" +JNIEXPORT jstring JNICALL +Java_im_molly_monero_WalletNative_nativeCreateSubAddress( + JNIEnv* env, + jobject thiz, + jlong handle, + jint sub_address_major) { + auto* wallet = reinterpret_cast(handle); + try { + return NativeToJavaString(env, wallet->createSubAddress(sub_address_major)); + } catch (error::account_index_outofbound& e) { + return nullptr; + } +} + +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_im_molly_monero_WalletNative_nativeGetSubAddresses( + JNIEnv* env, + jobject thiz, + jlong handle) { + auto* wallet = reinterpret_cast(handle); + return NativeToJavaStringArray(env, wallet->formatted_subaddresses()); +} + extern "C" JNIEXPORT jint JNICALL Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight( diff --git a/lib/android/src/main/cpp/wallet/wallet.h b/lib/android/src/main/cpp/wallet/wallet.h index eaeef0e..977a329 100644 --- a/lib/android/src/main/cpp/wallet/wallet.h +++ b/lib/android/src/main/cpp/wallet/wallet.h @@ -94,8 +94,9 @@ class Wallet : i_wallet2_callback { void cancelRefresh(); void setRefreshSince(long height_or_timestamp); - template - void withTxHistory(Consumer consumer); + std::string createSubAddressAccount(); + std::string createSubAddress(uint32_t index_major); + std::string addSubAddress(uint32_t index_major, uint32_t index_minor); std::unique_ptr createPayment( const std::vector& addresses, @@ -105,9 +106,13 @@ class Wallet : i_wallet2_callback { uint32_t account_index, const std::set& subaddr_indexes); + template + void withTxHistory(Consumer consumer); + std::vector fetchBaseFeeEstimate(); std::string public_address() const; + std::vector formatted_subaddresses(); uint32_t current_blockchain_height() const { return static_cast(m_last_block_height); } uint64_t current_blockchain_timestamp() const { return m_last_block_timestamp; } @@ -130,13 +135,15 @@ class Wallet : i_wallet2_callback { uint64_t m_last_block_height; uint64_t m_last_block_timestamp; + std::map m_subaddresses; + // Saved transaction history. std::vector m_tx_history; // Protects access to m_wallet instance and state fields. std::mutex m_wallet_mutex; std::mutex m_tx_history_mutex; - std::mutex m_refresh_mutex; + std::mutex m_subaddresses_mutex; // Reference to Kotlin wallet instance. const ScopedJavaGlobalRef m_callback; @@ -153,7 +160,9 @@ class Wallet : i_wallet2_callback { auto suspendRefreshAndRunLocked(T block) -> decltype(block()); void captureTxHistorySnapshot(std::vector& snapshot); - void handleNewBlock(uint64_t height, uint64_t timestmap); + void updateSubaddressMap(std::map& map); + std::string addSubaddressInternal(const cryptonote::subaddress_index& index); + void handleNewBlock(uint64_t height, uint64_t timestamp); void handleReorgEvent(uint64_t at_block_height); void handleMoneyEvent(uint64_t at_block_height); 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 555a86e..9376338 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt @@ -1,8 +1,8 @@ package im.molly.monero -import android.os.Parcel -import android.os.Parcelable +import android.annotation.SuppressLint +@SuppressLint("ParcelCreator") data class AccountAddress( val publicAddress: PublicAddress, val accountIndex: Int = 0, @@ -10,12 +10,12 @@ data class AccountAddress( ) : PublicAddress by publicAddress { val isPrimaryAddress: Boolean - get() = accountIndex == 0 && subAddressIndex == 0 + get() = subAddressIndex == 0 init { when (publicAddress) { - is StandardAddress -> require(isPrimaryAddress) { - "Only the primary address is a standard address" + is StandardAddress -> require(accountIndex == 0 && subAddressIndex == 0) { + "Only the account address 0/0 is a standard address" } is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1) { @@ -25,4 +25,24 @@ data class AccountAddress( else -> throw IllegalArgumentException("Unsupported address type") } } + + companion object { + fun parseWithIndexes(addressString: String): AccountAddress { + val parts = addressString.split("/") + require(parts.size == 3) { "Invalid account address format" } + val accountIndex = parts[0].toInt() + val subAddressIndex = parts[1].toInt() + val publicAddress = PublicAddress.parse(parts[2]) + return AccountAddress(publicAddress, accountIndex, subAddressIndex) + } + } + + 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/Balance.kt b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt index cc51ccf..e83bed6 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Balance.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt @@ -24,10 +24,10 @@ data class Balance( } fun Iterable>.calculateBalance(): Balance { - var pendingAmount = MoneroAmount.ZERO - val lockedAmounts = mutableListOf>() + var pendingAmount = MoneroAmount.ZERO + for (timeLocked in filter { !it.value.spent }) { if (timeLocked.value.age == 0) { pendingAmount += timeLocked.value.amount 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 94dcb90..de87332 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt @@ -3,7 +3,8 @@ package im.molly.monero //import im.molly.monero.proto.LedgerProto data class Ledger( - val primaryAddress: PublicAddress, + val publicAddress: PublicAddress, + val accountAddresses: Set, val transactions: Map, val enotes: Set>, val checkedAt: BlockchainTime, 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 e2817e3..4b870e1 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -21,10 +21,59 @@ class MoneroWallet internal constructor( private val logger = loggerFor() - val primaryAddress: PublicAddress = PublicAddress.parse(wallet.accountPrimaryAddress) + val publicAddress: PublicAddress = PublicAddress.parse(wallet.publicAddress) var dataStore by storageAdapter::dataStore + suspend fun getOrCreateAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = + suspendCancellableCoroutine { continuation -> + wallet.getOrCreateAddress( + accountIndex, + subAddressIndex, + object : BaseWalletCallbacks() { + override fun onAddressReady(subAddresses: Array) { + val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) + continuation.resume(accountAddress) {} + } + }) + } + + suspend fun createAccount(): AccountAddress = + suspendCancellableCoroutine { continuation -> + wallet.createAccount(object : BaseWalletCallbacks() { + override fun onAddressReady(subAddresses: Array) { + val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) + continuation.resume(accountAddress) {} + } + }) + } + + 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) {} + } + }) + } + + suspend fun getAllAddresses(): Set = + suspendCancellableCoroutine { continuation -> + wallet.getAllAddresses(object : BaseWalletCallbacks() { + override fun onAddressReady(subAddresses: Array) { + continuation.resume(subAddresses.toAccountAddresses()) {} + } + }) + } + + private fun Array.toAccountAddresses(): Set { + return map { AccountAddress.parseWithIndexes(it) }.toSet() + } + /** * A [Flow] of ledger changes. */ @@ -32,9 +81,23 @@ class MoneroWallet internal constructor( val listener = object : IBalanceListener.Stub() { lateinit var lastKnownLedger: Ledger - override fun onBalanceChanged(txHistory: List, blockchainTime: BlockchainTime) { - val (txs, spendableEnotes) = txHistory.consolidateTransactions(blockchainTime) - lastKnownLedger = Ledger(primaryAddress, txs, spendableEnotes, blockchainTime) + override fun onBalanceChanged( + txHistory: MutableList, + subAddresses: Array, + blockchainTime: BlockchainTime, + ) { + val accountAddresses = subAddresses.toAccountAddresses() + val (txById, enotes) = txHistory.consolidateTransactions( + accountAddresses = accountAddresses, + blockchainContext = blockchainTime, + ) + lastKnownLedger = Ledger( + publicAddress = publicAddress, + accountAddresses = accountAddresses, + transactions = txById, + enotes = enotes, + checkedAt = blockchainTime, + ) sendLedger(lastKnownLedger) } @@ -42,6 +105,14 @@ 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)) + } + } + private fun sendLedger(ledger: Ledger) { trySend(ledger).onFailure { logger.e("Too many ledger updates, channel capacity exceeded", it) @@ -126,7 +197,14 @@ class MoneroWallet internal constructor( override fun close() = wallet.close() } +class NoSuchAccountException(private val accountIndex: Int) : NoSuchElementException() { + override val message: String + get() = "No account was found with the specified index: $accountIndex" +} + 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 diff --git a/lib/android/src/main/kotlin/im/molly/monero/PendingTransfer.kt b/lib/android/src/main/kotlin/im/molly/monero/PendingTransfer.kt index e9c464f..c8fbfa9 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/PendingTransfer.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/PendingTransfer.kt @@ -4,7 +4,5 @@ class PendingTransfer internal constructor( private val pendingTransfer: IPendingTransfer, ) : AutoCloseable { - override fun close() { - pendingTransfer.close() - } + override fun close() = pendingTransfer.close() } 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 6ac7e74..d343c29 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/PublicAddress.kt @@ -13,9 +13,9 @@ sealed interface PublicAddress : Parcelable { fun isSubAddress(): Boolean companion object { - fun parse(publicAddress: String): PublicAddress { + fun parse(addressString: String): PublicAddress { val decoded = try { - publicAddress.decodeBase58() + addressString.decodeBase58() } catch (t: IllegalArgumentException) { throw InvalidAddress("Base58 decoding error", t) } @@ -25,10 +25,10 @@ sealed interface PublicAddress : Parcelable { return when (val prefix = decoded[0].toLong()) { in StandardAddress.prefixes -> { - StandardAddress(publicAddress, StandardAddress.prefixes[prefix]!!) + StandardAddress(addressString, StandardAddress.prefixes[prefix]!!) } in SubAddress.prefixes -> { - SubAddress(publicAddress, SubAddress.prefixes[prefix]!!) + SubAddress(addressString, SubAddress.prefixes[prefix]!!) } in IntegratedAddress.prefixes -> { TODO() diff --git a/lib/android/src/main/kotlin/im/molly/monero/RemoteNodeClient.kt b/lib/android/src/main/kotlin/im/molly/monero/RemoteNodeClient.kt index 18853c7..239118b 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/RemoteNodeClient.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/RemoteNodeClient.kt @@ -16,7 +16,7 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -// TODO: Hide IRemoteNodeClient methods and rename to HttpRpcClient +// TODO: Hide IRemoteNodeClient methods and rename to HttpRpcClient or MoneroNodeClient class RemoteNodeClient private constructor( val network: MoneroNetwork, private val loadBalancer: LoadBalancer, 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 3aff2ee..7267d33 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -102,7 +102,7 @@ internal class WalletNative private constructor( } } - override fun getAccountPrimaryAddress() = nativeGetAccountPrimaryAddress(handle) + override fun getPublicAddress() = nativeGetPublicAddress(handle) private fun MoneroNetwork.blockchainTime(height: Int, epochSecond: Long): BlockchainTime { // Block timestamp could be zero during a fast refresh. @@ -122,6 +122,9 @@ internal class WalletNative private constructor( val currentBalance: Balance get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance() + val subAddresses: Array + get() = nativeGetSubAddresses(handle) + private fun txHistorySnapshot(): List = nativeGetTxHistory(handle).toList() @GuardedBy("listenersLock") @@ -170,20 +173,22 @@ internal class WalletNative private constructor( } override fun createPayment(request: PaymentRequest, callback: ITransferRequestCallback) { - val (amounts, addresses) = request.paymentDetails.map { - it.amount.atomicUnits to it.recipientAddress.address - }.unzip() + scope.launch(singleThreadedDispatcher) { + val (amounts, addresses) = request.paymentDetails.map { + it.amount.atomicUnits to it.recipientAddress.address + }.unzip() - nativeCreatePayment( - handle = handle, - addresses = addresses.toTypedArray(), - amounts = amounts.toLongArray(), - timeLock = request.timeLock?.blockchainTime?.toLong() ?: 0, - priority = request.feePriority?.priority ?: 0, - accountIndex = 0, - subAddressIndexes = IntArray(0), - callback = callback, - ) + nativeCreatePayment( + handle = handle, + addresses = addresses.toTypedArray(), + amounts = amounts.toLongArray(), + timeLock = request.timeLock?.blockchainTime?.toLong() ?: 0, + priority = request.feePriority?.priority ?: 0, + accountIndex = 0, + subAddressIndexes = IntArray(0), + callback = callback, + ) + } } override fun createSweep(request: SweepRequest, callback: ITransferRequestCallback) { @@ -221,7 +226,7 @@ internal class WalletNative private constructor( override fun addBalanceListener(listener: IBalanceListener) { balanceListenersLock.withLock { balanceListeners.add(listener) - listener.onBalanceChanged(txHistorySnapshot(), currentBlockchainTime) + listener.onBalanceChanged(txHistorySnapshot(), subAddresses, currentBlockchainTime) } } @@ -231,15 +236,62 @@ internal class WalletNative private constructor( } } + override fun getOrCreateAddress( + accountIndex: Int, + subAddressIndex: Int, + callback: IWalletCallbacks?, + ) { + scope.launch(ioDispatcher) { + val subAddress = nativeAddSubAddress(handle, accountIndex, subAddressIndex) + notifyAddressCreation(subAddress, callback) + } + } + + override fun createAccount(callback: IWalletCallbacks?) { + scope.launch(ioDispatcher) { + val subAddress = nativeCreateSubAddressAccount(handle) + notifyAddressCreation(subAddress, callback) + } + } + + override fun createSubAddressForAccount(accountIndex: Int, callback: IWalletCallbacks?) { + scope.launch(ioDispatcher) { + val subAddress = nativeCreateSubAddress(handle, accountIndex) + if (subAddress != null) { + notifyAddressCreation(subAddress, callback) + } else { + callback?.onAddressReady(emptyArray()) + } + } + } + + private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) { + balanceListenersLock.withLock { + balanceListeners.forEach { listener -> + listener.onAddressCreated(subAddress) + } + } + callback?.onAddressReady(arrayOf(subAddress)) + } + + override fun getAllAddresses(callback: IWalletCallbacks) { + scope.launch(ioDispatcher) { + callback.onAddressReady(subAddresses) + } + } + @CalledByNative private fun onRefresh(height: Int, timestamp: Long, balanceChanged: Boolean) { balanceListenersLock.withLock { if (balanceListeners.isNotEmpty()) { - val call = fun(listener: IBalanceListener) { - val blockchainTime = network.blockchainTime(height, timestamp) - if (balanceChanged) { - listener.onBalanceChanged(txHistorySnapshot(), blockchainTime) - } else { + val blockchainTime = network.blockchainTime(height, timestamp) + val call = if (balanceChanged) { + val txHistory = txHistorySnapshot() + fun(listener: IBalanceListener) { + listener.onBalanceChanged(txHistory, subAddresses, blockchainTime) + } + } else { + fun(listener: IBalanceListener) { listener.onRefresh(blockchainTime) } } @@ -318,6 +370,12 @@ internal class WalletNative private constructor( const val REFRESH_ERROR: Int = 3 } + private external fun nativeAddSubAddress( + handle: Long, + subAddressMajor: Int, + subAddressMinor: Int, + ): String + private external fun nativeCancelRefresh(handle: Long) private external fun nativeCreate(networkId: Int): Long private external fun nativeCreatePayment( @@ -331,14 +389,15 @@ internal class WalletNative private constructor( callback: ITransferRequestCallback, ) + private external fun nativeCreateSubAddressAccount(handle: Long): String + private external fun nativeCreateSubAddress(handle: Long, subAddressMajor: Int): String? private external fun nativeDispose(handle: Long) private external fun nativeDisposePendingTransfer(handle: Long) + 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 nativeGetTxHistory(handle: Long): Array - private external fun nativeGetAccountPrimaryAddress(handle: Long): String - - // private external fun nativeGetAccountSubAddress(handle: Long, accountIndex: Int, subAddressIndex: Int): String private external fun nativeFetchBaseFeeEstimate(handle: Long): LongArray private external fun nativeLoad(handle: Long, fd: Int): Boolean private external fun nativeNonReentrantRefresh(handle: Long, skipCoinbase: Boolean): Int 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 aa93df6..16bfcd2 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 @@ -15,6 +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.isBlockHeightInRange import kotlinx.parcelize.Parcelize import java.time.Instant @@ -61,10 +62,12 @@ internal data class TxInfo @CalledByNative constructor( } internal fun List.consolidateTransactions( + accountAddresses: Set, blockchainContext: BlockchainTime, ): Pair, Set>> { // Extract enotes from incoming transactions - val allEnotes = filter { it.incoming }.map { it.toEnote(blockchainContext.height) } + val allEnotes = + filter { it.incoming }.map { it.toEnote(blockchainContext.height, accountAddresses) } val enoteByTxId = allEnotes.groupBy { enote -> enote.sourceTxId!! } @@ -149,13 +152,9 @@ private fun List.determineTxState(): TxState { } } -private fun TxInfo.toEnote(blockchainHeight: Int): Enote { - val ownerAddress = AccountAddress( - publicAddress = PublicAddress.parse(recipient!!), - accountIndex = subAddressMajor, - subAddressIndex = subAddressMinor - ) - +private fun TxInfo.toEnote(blockchainHeight: Int, accountAddresses: Set): Enote { + val ownerAddress = accountAddresses.findByIndexes(subAddressMajor, subAddressMinor) + ?: error("Failed to find account address for: $subAddressMajor/$subAddressMinor") val calculatedAge = if (height > 0) (blockchainHeight - height + 1) else 0 return Enote( diff --git a/lib/android/src/main/kotlin/im/molly/monero/loadbalancer/LoadBalancer.kt b/lib/android/src/main/kotlin/im/molly/monero/loadbalancer/LoadBalancer.kt index b71a794..ee8c90f 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/loadbalancer/LoadBalancer.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/loadbalancer/LoadBalancer.kt @@ -30,24 +30,24 @@ class LoadBalancer( } } -sealed interface RemoteNodeState { +sealed interface ConnectionState { /** * The remote node is currently online and able to handle requests. */ - data class Online(val responseTime: Duration) : RemoteNodeState + data class Online(val responseTime: Duration) : ConnectionState /** * The client's request has timed out and no response has been received. */ - data class Timeout(val cause: Throwable?) + data class Timeout(val cause: Throwable?) : ConnectionState /** * Indicates that an error occurred while processing the client's request to the remote node. */ -// open data class Error(val message: String?) : RemoteNodeState { + sealed class Error(val message: String?) : ConnectionState /** * Indicates that the client is unauthorized to access the remote node, i.e. the client's credentials were invalid. */ -// data class Unauthorized(override val message: String?) : Error + data object Unauthorized : Error("Unauthorized") } diff --git a/vendor/monero b/vendor/monero index 6063fbe..36d6a9c 160000 --- a/vendor/monero +++ b/vendor/monero @@ -1 +1 @@ -Subproject commit 6063fbeb1414eab1c027ee57b4f2834bb178af7e +Subproject commit 36d6a9c4c68a9c787a14d419edc05384fe1c506d