From 48fb61d1b361cb21d53d5848e86f448dd8b39357 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Wed, 11 Oct 2023 00:52:55 +0200 Subject: [PATCH] lib: transaction history processing --- .../molly/monero/demo/data/MoneroSdkClient.kt | 2 +- .../monero/demo/data/WalletRepository.kt | 2 +- .../monero/demo/ui/AddWalletViewModel.kt | 14 +- .../im/molly/monero/demo/ui/WalletScreen.kt | 2 +- .../im/molly/monero/IBalanceListener.aidl | 6 +- .../main/aidl/im/molly/monero/IWallet.aidl | 2 +- .../im/molly/monero/IWalletCallbacks.aidl | 2 +- .../main/aidl/im/molly/monero/OwnedTxOut.aidl | 3 - .../aidl/im/molly/monero/internal/TxInfo.aidl | 3 + lib/android/src/main/cpp/jni_cache.cc | 14 +- lib/android/src/main/cpp/jni_cache.h | 4 +- lib/android/src/main/cpp/wallet.cc | 320 ++++++++++++++---- lib/android/src/main/cpp/wallet.h | 97 ++++-- .../kotlin/im/molly/monero/AccountAddress.kt | 19 ++ .../kotlin/im/molly/monero/AtomicAmount.kt | 18 +- .../main/kotlin/im/molly/monero/Balance.kt | 40 ++- .../src/main/kotlin/im/molly/monero/Block.kt | 25 ++ .../kotlin/im/molly/monero/BlockchainTime.kt | 108 ++++++ .../src/main/kotlin/im/molly/monero/Enote.kt | 14 + .../main/kotlin/im/molly/monero/HashDigest.kt | 8 + .../src/main/kotlin/im/molly/monero/Ledger.kt | 9 +- .../kotlin/im/molly/monero/MoneroCurrency.kt | 3 +- .../kotlin/im/molly/monero/MoneroWallet.kt | 23 +- .../kotlin/im/molly/monero/ProtocolInfo.kt | 3 + .../main/kotlin/im/molly/monero/PublicKey.kt | 7 +- .../kotlin/im/molly/monero/RestorePoint.kt | 26 -- .../main/kotlin/im/molly/monero/SecretKey.kt | 24 +- .../kotlin/im/molly/monero/Transaction.kt | 44 +++ .../kotlin/im/molly/monero/WalletNative.kt | 26 +- .../kotlin/im/molly/monero/WalletProvider.kt | 4 +- .../kotlin/im/molly/monero/internal/TxInfo.kt | 153 +++++++++ .../monero/internal/constants/Constants.kt | 6 + .../kotlin/im/molly/monero/util/Base58.kt | 7 +- .../kotlin/im/molly/monero/util/HexFormat.kt | 2 + .../kotlin/im/molly/monero/SecretKeyTest.kt | 21 +- 35 files changed, 865 insertions(+), 196 deletions(-) delete mode 100644 lib/android/src/main/aidl/im/molly/monero/OwnedTxOut.aidl create mode 100644 lib/android/src/main/aidl/im/molly/monero/internal/TxInfo.aidl create mode 100644 lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/Block.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/Enote.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/HashDigest.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/ProtocolInfo.kt delete mode 100644 lib/android/src/main/kotlin/im/molly/monero/RestorePoint.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/Transaction.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt create mode 100644 lib/android/src/main/kotlin/im/molly/monero/internal/constants/Constants.kt diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/data/MoneroSdkClient.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/data/MoneroSdkClient.kt index 064f5aa..8e1a9f3 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/data/MoneroSdkClient.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/data/MoneroSdkClient.kt @@ -32,7 +32,7 @@ class MoneroSdkClient(private val context: Context) { network: MoneroNetwork, filename: String, secretSpendKey: SecretKey, - restorePoint: RestorePoint, + restorePoint: BlockchainTime, ): MoneroWallet { val provider = providerDeferred.await() return provider.restoreWallet( 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 0ee65a6..f39e139 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 @@ -79,7 +79,7 @@ class WalletRepository( name: String, remoteNodeIds: List, secretSpendKey: SecretKey, - restorePoint: RestorePoint, + restorePoint: BlockchainTime, ): Pair { val uniqueFilename = UUID.randomUUID().toString() val wallet = moneroSdkClient.restoreWallet( diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt index 2807245..a833a84 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import im.molly.monero.MoneroNetwork -import im.molly.monero.RestorePoint +import im.molly.monero.BlockchainTime import im.molly.monero.SecretKey import im.molly.monero.demo.AppModule import im.molly.monero.demo.data.RemoteNodeRepository @@ -96,19 +96,17 @@ class AddWalletViewModel( creationDate.isEmpty() || runCatching { LocalDate.parse(creationDate) }.isSuccess fun validateRestoreHeight(): Boolean = - restoreHeight.isEmpty() || runCatching { RestorePoint(restoreHeight.toLong()) }.isSuccess + restoreHeight.isEmpty() || runCatching { BlockchainTime.Block(restoreHeight.toInt()) }.isSuccess fun createWallet() = viewModelScope.launch { walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds()) } fun restoreWallet() = viewModelScope.launch { - val restorePoint = if (creationDate.isNotEmpty()) { - RestorePoint(creationDate = LocalDate.parse(creationDate)) - } else if (restoreHeight.isNotEmpty()) { - RestorePoint(blockHeight = restoreHeight.toLong()) - } else { - RestorePoint(blockHeight = 0) + val restorePoint = when { + creationDate.isNotEmpty() -> BlockchainTime.Timestamp(LocalDate.parse(creationDate)) + restoreHeight.isNotEmpty() -> BlockchainTime.Block(restoreHeight.toInt()) + else -> BlockchainTime.Genesis } SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey -> walletRepository.restoreWallet( 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 2f714c6..da51f67 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 @@ -112,7 +112,7 @@ private fun WalletScreenPopulated( text = buildAnnotatedString { append(MoneroCurrency.symbol + " ") withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(MoneroCurrency.format(ledger.balance.totalAmount)) + append(MoneroCurrency.format(ledger.balance.confirmedBalance)) } } ) 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 4981718..2a0b65f 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IBalanceListener.aidl @@ -1,8 +1,8 @@ package im.molly.monero; -import im.molly.monero.OwnedTxOut; +import im.molly.monero.internal.TxInfo; oneway interface IBalanceListener { - void onBalanceChanged(in List txOuts, long checkedAtBlockHeight); - void onRefresh(long blockchainHeight); + void onBalanceChanged(in List txHistory, int blockchainHeight); + void onRefresh(int blockchainHeight); } 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 eb1293b..aa25728 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWallet.aidl @@ -4,7 +4,7 @@ import im.molly.monero.IBalanceListener; import im.molly.monero.IWalletCallbacks; interface IWallet { - String getPrimaryAccountAddress(); + String getAccountPrimaryAddress(); void addBalanceListener(in IBalanceListener listener); void removeBalanceListener(in IBalanceListener listener); oneway void resumeRefresh(boolean skipCoinbaseOutputs, in IWalletCallbacks callback); 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 a4525ef..33c2073 100644 --- a/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/IWalletCallbacks.aidl @@ -1,6 +1,6 @@ package im.molly.monero; oneway interface IWalletCallbacks { - void onRefreshResult(long blockHeight, int status); + void onRefreshResult(int blockHeight, int status); void onCommitResult(boolean success); } diff --git a/lib/android/src/main/aidl/im/molly/monero/OwnedTxOut.aidl b/lib/android/src/main/aidl/im/molly/monero/OwnedTxOut.aidl deleted file mode 100644 index ad57cc5..0000000 --- a/lib/android/src/main/aidl/im/molly/monero/OwnedTxOut.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package im.molly.monero; - -parcelable OwnedTxOut; diff --git a/lib/android/src/main/aidl/im/molly/monero/internal/TxInfo.aidl b/lib/android/src/main/aidl/im/molly/monero/internal/TxInfo.aidl new file mode 100644 index 0000000..902762f --- /dev/null +++ b/lib/android/src/main/aidl/im/molly/monero/internal/TxInfo.aidl @@ -0,0 +1,3 @@ +package im.molly.monero.internal; + +parcelable TxInfo; diff --git a/lib/android/src/main/cpp/jni_cache.cc b/lib/android/src/main/cpp/jni_cache.cc index 881a23c..0534116 100644 --- a/lib/android/src/main/cpp/jni_cache.cc +++ b/lib/android/src/main/cpp/jni_cache.cc @@ -3,12 +3,12 @@ namespace monero { // im.molly.monero -ScopedJvmGlobalRef OwnedTxOut; +ScopedJvmGlobalRef TxInfoClass; jmethodID HttpResponse_getBody; jmethodID HttpResponse_getCode; jmethodID HttpResponse_getContentType; jmethodID Logger_logFromNative; -jmethodID OwnedTxOut_ctor; +jmethodID TxInfo_ctor; jmethodID WalletNative_callRemoteNode; jmethodID WalletNative_onRefresh; jmethodID WalletNative_onSuspendRefresh; @@ -20,9 +20,11 @@ void initializeJniCache(JNIEnv* env) { // im.molly.monero auto httpResponse = findClass(env, "im/molly/monero/HttpResponse"); auto logger = findClass(env, "im/molly/monero/Logger"); - auto ownedTxOut = findClass(env, "im/molly/monero/OwnedTxOut"); + auto txInfoClass = findClass(env, "im/molly/monero/internal/TxInfo"); auto walletNative = findClass(env, "im/molly/monero/WalletNative"); + TxInfoClass = txInfoClass; + HttpResponse_getBody = httpResponse .getMethodId(env, "getBody", "()Landroid/os/ParcelFileDescriptor;"); HttpResponse_getCode = httpResponse @@ -31,8 +33,8 @@ void initializeJniCache(JNIEnv* env) { .getMethodId(env, "getContentType", "()Ljava/lang/String;"); Logger_logFromNative = logger .getMethodId(env, "logFromNative", "(ILjava/lang/String;Ljava/lang/String;)V"); - OwnedTxOut_ctor = ownedTxOut - .getMethodId(env, "", "([BJJJ)V"); + TxInfo_ctor = txInfoClass + .getMethodId(env, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;JIIJJJJZZ)V"); WalletNative_callRemoteNode = walletNative .getMethodId(env, "callRemoteNode", @@ -42,8 +44,6 @@ void initializeJniCache(JNIEnv* env) { WalletNative_onSuspendRefresh = walletNative .getMethodId(env, "onSuspendRefresh", "(Z)V"); - OwnedTxOut = ownedTxOut; - // android.os auto parcelFileDescriptor = findClass(env, "android/os/ParcelFileDescriptor"); diff --git a/lib/android/src/main/cpp/jni_cache.h b/lib/android/src/main/cpp/jni_cache.h index ffab3cb..bcd4081 100644 --- a/lib/android/src/main/cpp/jni_cache.h +++ b/lib/android/src/main/cpp/jni_cache.h @@ -9,12 +9,12 @@ namespace monero { void initializeJniCache(JNIEnv* env); // im.molly.monero -extern ScopedJvmGlobalRef OwnedTxOut; +extern ScopedJvmGlobalRef TxInfoClass; extern jmethodID HttpResponse_getBody; extern jmethodID HttpResponse_getCode; extern jmethodID HttpResponse_getContentType; extern jmethodID Logger_logFromNative; -extern jmethodID OwnedTxOut_ctor; +extern jmethodID TxInfo_ctor; extern jmethodID WalletNative_callRemoteNode; extern jmethodID WalletNative_onRefresh; extern jmethodID WalletNative_onSuspendRefresh; diff --git a/lib/android/src/main/cpp/wallet.cc b/lib/android/src/main/cpp/wallet.cc index d4ba51d..0a551c0 100644 --- a/lib/android/src/main/cpp/wallet.cc +++ b/lib/android/src/main/cpp/wallet.cc @@ -11,15 +11,20 @@ #include "eraser.h" #include "fd.h" +#include "string_tools.h" + namespace io = boost::iostreams; namespace monero { using namespace std::chrono_literals; +using namespace epee::string_tools; static_assert(COIN == 1e12, "Monero atomic unit must be 1e-12 XMR"); static_assert(CRYPTONOTE_MAX_BLOCK_NUMBER == 500000000, "Min timestamp must be higher than max block height"); +static_assert(CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE == 10, ""); // TODO +static_assert(DIFFICULTY_TARGET_V2 == 120, ""); Wallet::Wallet( JNIEnv* env, @@ -93,14 +98,14 @@ bool Wallet::parseFrom(std::istream& input) { if (!serialization::serialize(ar, m_wallet)) return false; m_blockchain_height = m_wallet.get_blockchain_current_height(); - m_wallet.get_transfers(m_tx_outs); + captureTxHistorySnapshot(m_tx_history); m_account_ready = true; return true; } bool Wallet::writeTo(std::ostream& output) { return suspendRefreshAndRunLocked([&]() -> bool { - binary_archive ar(output); + binary_archive < true > ar(output); if (!serialization::serialize_noeof(ar, *this)) return false; if (!serialization::serialize_noeof(ar, require_account())) @@ -111,10 +116,10 @@ bool Wallet::writeTo(std::ostream& output) { }); } -template -void Wallet::getOwnedTxOuts(Callback callback) { - std::lock_guard lock(m_tx_outs_mutex); - callback(m_tx_outs); +template +void Wallet::withTxHistory(Consumer consumer) { + std::lock_guard lock(m_tx_history_mutex); + consumer(m_tx_history); } std::string Wallet::public_address() const { @@ -127,36 +132,228 @@ cryptonote::account_base& Wallet::require_account() { return m_wallet.get_account(); } -// Reading m_transfers from wallet2 is not guarded by any lock; call this function only -// from wallet2's callback thread. -void Wallet::handleBalanceChanged(uint64_t at_block_height) { - LOGV("handleBalanceChanged(%lu)", at_block_height); - m_tx_outs_mutex.lock(); - m_wallet.get_transfers(m_tx_outs); - m_tx_outs_mutex.unlock(); - m_blockchain_height = at_block_height; - callOnRefresh(true); -} - -void Wallet::handleNewBlock(uint64_t height) { - m_blockchain_height = height; - // Notify the blockchain height once every 200 ms if the height is a multiple of 100. - bool debounce = true; - if (height % 100 == 0) { - static std::chrono::steady_clock::time_point last_time; - auto now = std::chrono::steady_clock::now(); - if (now - last_time >= 200.ms) { - last_time = now; - debounce = false; +const payment_details* find_matching_payment( + const std::list> pds, + uint64_t amount, + const crypto::hash& txid, + const cryptonote::subaddress_index& subaddr_index) { + if (txid == crypto::null_hash) { + return nullptr; + } + for (const auto& p: pds) { + const auto& pd = p.second; + if (pd.m_amount == amount && pd.m_tx_hash == txid && pd.m_subaddr_index == subaddr_index) { + return &pd; } } - if (!debounce) { - callOnRefresh(false); + return nullptr; +}; + +// Only call this function from the callback thread or during initialization, +// as there is no locking mechanism to safeguard reading transaction history +// from wallet2. +void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { + snapshot.clear(); + + std::vector tds; + m_wallet.get_transfers(tds); + + uint64_t min_height = 0; + + std::list> pds; + std::list> upds; + std::list> txs; + std::list> utxs; + m_wallet.get_payments(pds, min_height); + m_wallet.get_unconfirmed_payments(upds, min_height); + m_wallet.get_payments_out(txs, min_height); + m_wallet.get_unconfirmed_payments_out(utxs); + + // Iterate through the known owned outputs (incoming transactions). + for (const auto& td: tds) { + snapshot.emplace_back(td.m_txid, TxInfo::INCOMING); + TxInfo& recv = snapshot.back(); + recv.m_key = td.get_public_key(); + recv.m_key_image = td.m_key_image; + 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; + + // Check if the payment exists and update metadata if found. + const auto* pd = find_matching_payment(pds, td.m_amount, td.m_txid, td.m_subaddr_index); + if (pd) { + recv.m_height = pd->m_block_height; + recv.m_timestamp = pd->m_timestamp; + recv.m_fee = pd->m_fee; + recv.m_coinbase = pd->m_coinbase; + recv.m_state = TxInfo::ON_CHAIN; + } else { + recv.m_state = TxInfo::OFF_CHAIN; + } + } + + // Confirmed outgoing transactions. + for (const auto& pair: txs) { + const auto& tx = pair.second; + uint64_t fee = tx.m_amount_in - tx.m_amount_out; + + for (const auto& dest: tx.m_dests) { + snapshot.emplace_back(pair.first, TxInfo::OUTGOING); + TxInfo& spent = snapshot.back(); + spent.m_recipient = dest.address(m_wallet.nettype(), tx.m_payment_id); + spent.m_amount = dest.amount; + spent.m_height = tx.m_block_height; + spent.m_unlock_time = tx.m_unlock_time; + spent.m_timestamp = tx.m_timestamp; + spent.m_fee = fee; + spent.m_change = tx.m_change; + spent.m_state = TxInfo::ON_CHAIN; + } + + for (const auto& in: tx.m_tx.vin) { + if (in.type() != typeid(cryptonote::txin_to_key)) continue; + const auto& txin = boost::get(in); + snapshot.emplace_back(pair.first, TxInfo::OUTGOING); + TxInfo& spent = snapshot.back(); + spent.m_key_image = txin.k_image; + spent.m_key_image_known = true; + spent.m_amount = txin.amount; + spent.m_height = tx.m_block_height; + spent.m_unlock_time = tx.m_unlock_time; + spent.m_timestamp = tx.m_timestamp; + spent.m_fee = fee; + spent.m_state = TxInfo::ON_CHAIN; + } + } + + // Unconfirmed outgoing transactions. + for (const auto& pair: utxs) { + const auto& utx = pair.second; + uint64_t fee = utx.m_amount_in - utx.m_amount_out; + auto state = (utx.m_state == unconfirmed_transfer_details::pending) + ? TxInfo::PENDING + : TxInfo::FAILED; + + for (const auto& dest: utx.m_dests) { + if (const auto dest_subaddr_idx = m_wallet.get_subaddress_index(dest.addr)) { + // Add pending transfers to our own wallet. + snapshot.emplace_back(pair.first, TxInfo::INCOMING); + TxInfo& recv = snapshot.back(); + // TODO: recv.m_key + 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; + recv.m_unlock_time = utx.m_tx.unlock_time; + recv.m_timestamp = utx.m_timestamp; + recv.m_fee = fee; + recv.m_state = state; + } else { + snapshot.emplace_back(pair.first, TxInfo::OUTGOING); + TxInfo& spent = snapshot.back(); + spent.m_recipient = dest.address(m_wallet.nettype(), utx.m_payment_id); + spent.m_amount = dest.amount; + spent.m_unlock_time = utx.m_tx.unlock_time; + spent.m_timestamp = utx.m_timestamp; + spent.m_fee = fee; + spent.m_change = utx.m_change; + spent.m_state = state; + } + } + + // Change is ours too. + if (utx.m_change > 0) { + snapshot.emplace_back(pair.first, TxInfo::INCOMING); + TxInfo& change = snapshot.back(); + // TODO: change.m_key + 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; + change.m_unlock_time = utx.m_tx.unlock_time; + change.m_timestamp = utx.m_timestamp; + change.m_fee = fee; + change.m_state = state; + } + + for (const auto& in: utx.m_tx.vin) { + if (in.type() != typeid(cryptonote::txin_to_key)) continue; + const auto& txin = boost::get(in); + snapshot.emplace_back(pair.first, TxInfo::OUTGOING); + TxInfo& spent = snapshot.back(); + spent.m_key_image = txin.k_image; + spent.m_key_image_known = true; + spent.m_amount = txin.amount; + spent.m_timestamp = utx.m_timestamp; + spent.m_fee = fee; + spent.m_state = state; + } + } + + // Add outputs of unconfirmed payments pending in the pool. + for (const auto& pair: upds) { + const auto& upd = pair.second.m_pd; + bool double_spend_seen = pair.second.m_double_spend_seen; // Unused + // Denormalize individual amounts sent to a single subaddress in a single tx. + for (uint64_t amount: upd.m_amounts) { + snapshot.emplace_back(upd.m_tx_hash, TxInfo::INCOMING); + TxInfo& recv = snapshot.back(); + // TODO: recv.m_key + 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; + recv.m_height = upd.m_block_height; + recv.m_unlock_time = upd.m_unlock_time; + recv.m_timestamp = upd.m_timestamp; + recv.m_fee = upd.m_fee; + recv.m_coinbase = upd.m_coinbase; + recv.m_state = TxInfo::PENDING; + } } } -void Wallet::callOnRefresh(bool balance_changed) { - m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh, m_blockchain_height, balance_changed); +void Wallet::handleNewBlock(uint64_t height, bool refresh_running) { + m_blockchain_height = height; + if (m_balance_changed) { + m_tx_history_mutex.lock(); + captureTxHistorySnapshot(m_tx_history); + m_tx_history_mutex.unlock(); + } + notifyRefresh(!m_balance_changed && refresh_running); + m_balance_changed = false; +} + +void Wallet::handleReorgEvent(uint64_t at_block_height) { + m_balance_changed = true; +} + +void Wallet::handleMoneyEvent(uint64_t at_block_height) { + m_balance_changed = true; +} + +void Wallet::notifyRefresh(bool debounce) { + static std::chrono::steady_clock::time_point last_time; + // If debouncing is requested and the blockchain height is a multiple of 100, it limits + // the notifications to once every 200 ms. + if (debounce) { + if (m_blockchain_height % 100 == 0) { + auto now = std::chrono::steady_clock::now(); + if (now - last_time >= 200.ms) { + last_time = now; + debounce = false; + } + } + } else { + last_time = std::chrono::steady_clock::now(); + } + if (!debounce) { + m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh, + m_blockchain_height, m_balance_changed); + } } Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) { @@ -170,7 +367,7 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) { m_wallet.set_refresh_from_block_height(m_restore_height); try { // refresh() will block until stop() is called or it syncs successfully. - m_wallet.refresh(false); + m_wallet.refresh(false /* trusted_daemon */); if (!m_wallet.stopped()) { m_wallet.stop(); ret = Status::OK; @@ -190,9 +387,8 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) { ret = Status::INTERRUPTED; } m_refresh_running.store(false); - m_blockchain_height = m_wallet.get_blockchain_current_height(); - // Always notify the last block height. - callOnRefresh(false); + // Ensure the latest block and pool state are consistently processed. + handleNewBlock(m_wallet.get_blockchain_current_height(), false); return ret; } @@ -214,6 +410,7 @@ auto Wallet::suspendRefreshAndRunLocked(T block) -> decltype(block()) { m_callback.callVoidMethod(env, WalletNative_onSuspendRefresh, false); m_refresh_cond.notify_one(); } + // Call the lambda and release the mutex upon completion. return block(); } @@ -347,7 +544,7 @@ Java_im_molly_monero_WalletNative_nativeSetRefreshSince( extern "C" JNIEXPORT jstring JNICALL -Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress( +Java_im_molly_monero_WalletNative_nativeGetAccountPrimaryAddress( JNIEnv* env, jobject thiz, jlong handle) { @@ -356,47 +553,54 @@ Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress( } extern "C" -JNIEXPORT jlong JNICALL +JNIEXPORT jint JNICALL Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight( JNIEnv* env, jobject thiz, jlong handle) { auto* wallet = reinterpret_cast(handle); uint64_t height = wallet->current_blockchain_height(); - LOG_FATAL_IF(height > std::numeric_limits::max(), - "Blockchain height overflowed jlong"); - return static_cast(height); + LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, + "Blockchain max height reached"); + return static_cast(height); } -ScopedJvmLocalRef nativeToJvmOwnedTxOut(JNIEnv* env, - const TxOut& tx_out) { - LOG_FATAL_IF(tx_out.m_spent - && (tx_out.m_spent_height == 0 || - tx_out.m_spent_height < tx_out.m_block_height), - "Unexpected spent block height in tx output"); - return {env, OwnedTxOut.newObject( - env, - OwnedTxOut_ctor, - nativeToJvmByteArray(env, tx_out.m_txid.data, sizeof(tx_out.m_txid.data)).obj(), - tx_out.m_amount, - tx_out.m_block_height, - tx_out.m_spent_height) +ScopedJvmLocalRef nativeToJvmTxInfo(JNIEnv* env, + const TxInfo& info) { + LOG_FATAL_IF(info.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, + "Blockchain max height reached"); + return {env, TxInfoClass.newObject( + env, TxInfo_ctor, + nativeToJvmString(env, pod_to_hex(info.m_tx_hash)).obj(), + nativeToJvmString(env, pod_to_hex(info.m_key)).obj(), + info.m_key_image_known ? nativeToJvmString(env, pod_to_hex(info.m_key_image)).obj(): nullptr, + info.m_subaddress_major, + info.m_subaddress_minor, + (!info.m_recipient.empty()) ? nativeToJvmString(env, info.m_recipient).obj() : nullptr, + info.m_amount, + static_cast(info.m_height), + info.m_state, + info.m_unlock_time, + info.m_timestamp, + info.m_fee, + info.m_coinbase, + info.m_type == TxInfo::INCOMING) }; } extern "C" JNIEXPORT jobjectArray JNICALL -Java_im_molly_monero_WalletNative_nativeGetOwnedTxOuts( +Java_im_molly_monero_WalletNative_nativeGetTxHistory( JNIEnv* env, jobject thiz, jlong handle) { auto* wallet = reinterpret_cast(handle); ScopedJvmLocalRef j_array; - wallet->getOwnedTxOuts([env, &j_array](std::vector const& tx_outs) { + wallet->withTxHistory([env, &j_array](std::vector const& txs) { j_array = nativeToJvmObjectArray(env, - tx_outs, - OwnedTxOut.getClass(), - &nativeToJvmOwnedTxOut); + txs, + TxInfoClass.getClass(), + &nativeToJvmTxInfo); }); return j_array.Release(); } diff --git a/lib/android/src/main/cpp/wallet.h b/lib/android/src/main/cpp/wallet.h index 4ee6f08..9fea087 100644 --- a/lib/android/src/main/cpp/wallet.h +++ b/lib/android/src/main/cpp/wallet.h @@ -1,7 +1,7 @@ #ifndef WALLET_H_ #define WALLET_H_ -#include +#include #include "http_client.h" #include "jvm.h" @@ -10,7 +10,61 @@ namespace monero { -using TxOut = tools::wallet2::transfer_details; +using transfer_details = tools::wallet2::transfer_details; +using payment_details = tools::wallet2::payment_details; +using pool_payment_details = tools::wallet2::pool_payment_details; +using confirmed_transfer_details = tools::wallet2::confirmed_transfer_details; +using unconfirmed_transfer_details = tools::wallet2::unconfirmed_transfer_details; + +// Basic structure combining transaction details with input or output info. +struct TxInfo { + crypto::hash m_tx_hash; + crypto::public_key m_key; + crypto::key_image m_key_image; + uint32_t m_subaddress_major; + uint32_t m_subaddress_minor; + std::string m_recipient; + uint64_t m_amount; + uint64_t m_height; + uint64_t m_unlock_time; + uint64_t m_timestamp; + uint64_t m_fee; + uint64_t m_change; + bool m_coinbase; + bool m_key_image_known; + + enum TxType { + INCOMING = 0, + OUTGOING = 1, + } m_type; + + enum TxState { + OFF_CHAIN = 0, + PENDING = 1, + FAILED = 2, + ON_CHAIN = 3, + } m_state; + + TxInfo(crypto::hash tx_hash, TxType type): + m_tx_hash(tx_hash), + m_key(crypto::public_key{}), + m_key_image(crypto::key_image{}), + m_subaddress_major(-1), + m_subaddress_minor(-1), + m_recipient(), + m_amount(0), + m_height(0), + m_unlock_time(0), + m_timestamp(0), + m_fee(0), + m_change(0), + m_coinbase(false), + m_key_image_known(false), + m_type(type), + m_state(OFF_CHAIN) {} + + // TODO: Factory functions for various types of transactions. +}; // Wrapper for wallet2.h core API. class Wallet : tools::i_wallet2_callback { @@ -36,14 +90,14 @@ class Wallet : tools::i_wallet2_callback { void cancelRefresh(); void setRefreshSince(long height_or_timestamp); - template - void getOwnedTxOuts(Callback callback); + template + void withTxHistory(Consumer consumer); std::string public_address() const; uint64_t current_blockchain_height() const { return m_blockchain_height; } - // Extra state that must be persistent and isn't restored by wallet2's serializer. + // Extra state that must be persistent but isn't restored by wallet2's serializer. BEGIN_SERIALIZE_OBJECT() VERSION_FIELD(0) FIELD(m_restore_height) @@ -54,14 +108,16 @@ class Wallet : tools::i_wallet2_callback { tools::wallet2 m_wallet; - std::vector m_tx_outs; bool m_account_ready; uint64_t m_restore_height; uint64_t m_blockchain_height; + // 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_outs_mutex; + std::mutex m_tx_history_mutex; std::mutex m_refresh_mutex; // Reference to Kotlin wallet instance. @@ -70,25 +126,26 @@ class Wallet : tools::i_wallet2_callback { std::condition_variable m_refresh_cond; std::atomic m_refresh_running; bool m_refresh_canceled; + bool m_balance_changed; + + void notifyRefresh(bool debounce); template auto suspendRefreshAndRunLocked(T block) -> decltype(block()); - void handleBalanceChanged(uint64_t at_block_height); - void handleNewBlock(uint64_t height); - - void callOnRefresh(bool balance_changed); + void captureTxHistorySnapshot(std::vector& snapshot); + void handleNewBlock(uint64_t height, bool refresh_running); + void handleReorgEvent(uint64_t at_block_height); + void handleMoneyEvent(uint64_t at_block_height); // Implementation of i_wallet2_callback follows. private: void on_new_block(uint64_t height, const cryptonote::block& block) override { - handleNewBlock(height); + handleNewBlock(height, true); } - void on_reorg(uint64_t height, size_t blocks_detached, size_t transfers_detached) override { - if (transfers_detached > 0) { - handleBalanceChanged(height); - } + void on_reorg(uint64_t height) override { + handleReorgEvent(height); } void on_money_received(uint64_t height, @@ -99,7 +156,7 @@ class Wallet : tools::i_wallet2_callback { const cryptonote::subaddress_index& subaddr_index, bool is_change, uint64_t unlock_time) override { - handleBalanceChanged(height); + handleMoneyEvent(height); } void on_unconfirmed_money_received(uint64_t height, @@ -107,8 +164,8 @@ class Wallet : tools::i_wallet2_callback { const cryptonote::transaction& tx, uint64_t amount, const cryptonote::subaddress_index& subaddr_index) override { - handleBalanceChanged(height); - }; + handleMoneyEvent(height); + } void on_money_spent(uint64_t height, const crypto::hash& txid, @@ -116,7 +173,7 @@ class Wallet : tools::i_wallet2_callback { uint64_t amount, const cryptonote::transaction& spend_tx, const cryptonote::subaddress_index& subaddr_index) override { - handleBalanceChanged(height); + handleMoneyEvent(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 new file mode 100644 index 0000000..8dcade7 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/AccountAddress.kt @@ -0,0 +1,19 @@ +package im.molly.monero + +data class AccountAddress( + val publicAddress: PublicAddress, + val accountIndex: Int = 0, + val subAddressIndex: Int = 0, +) : PublicAddress by publicAddress { + + init { + when (publicAddress) { + is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1) + else -> require(accountIndex == 0 && subAddressIndex == 0) + } + } + + fun belongsTo(targetAccountIndex: Int): Boolean { + return accountIndex == targetAccountIndex + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt b/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt index 8e0d7d5..3752757 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/AtomicAmount.kt @@ -3,12 +3,20 @@ package im.molly.monero import android.os.Parcelable import kotlinx.parcelize.Parcelize +// TODO: Rename to MoneroAmount? + @JvmInline @Parcelize value class AtomicAmount(val value: Long) : Parcelable { operator fun plus(other: AtomicAmount) = AtomicAmount(Math.addExact(this.value, other.value)) + operator fun minus(other: AtomicAmount) = AtomicAmount(Math.subtractExact(this.value, other.value)) + operator fun compareTo(other: Int): Int = value.compareTo(other) + + companion object { + val ZERO = AtomicAmount(0) + } } fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this) @@ -16,9 +24,17 @@ 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() + var sum: AtomicAmount = AtomicAmount.ZERO for (element in this) { sum += selector(element) } return sum } + +fun Iterable.sum(): AtomicAmount { + var sum: AtomicAmount = AtomicAmount.ZERO + for (element in this) { + sum += 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 8b6a96f..9943d26 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Balance.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Balance.kt @@ -1,23 +1,31 @@ package im.molly.monero data class Balance( - private val spendableTxOuts: Set, + val pendingBalance: AtomicAmount, + val timeLockedAmounts: Set>, ) { - val totalAmount: AtomicAmount = spendableTxOuts.sumOf { it.amount } + val confirmedBalance: AtomicAmount = timeLockedAmounts.sumOf { it.value } - fun totalAmountUnlockedAt( - blockHeight: Long, - timestampMillis: Long = System.currentTimeMillis() - // TODO: Create Timelock class - ): AtomicAmount { - require(blockHeight > 0) - require(timestampMillis >= 0) - TODO() - } + fun unlockedBalance(currentTime: BlockchainTime): AtomicAmount = + timeLockedAmounts + .mapNotNull { it.getValueIfUnlocked(currentTime) } + .sum() - companion object { - fun of(txOuts: List) = Balance( - spendableTxOuts = txOuts.filter { it.notSpent }.toSet(), - ) - } + fun lockedBalance(currentTime: BlockchainTime): Map = + timeLockedAmounts + .filter { it.isLocked(currentTime) } + .groupBy({ it.timeUntilUnlock(currentTime) }, { it.value }) + .mapValues { (_, amounts) -> amounts.sum() } +} + +fun Iterable>.balance(subAccountSelector: (Int) -> Boolean = { true }): Balance { + val enotes = filter { subAccountSelector(it.value.owner.accountIndex) } + val (pending, confirmed) = enotes.partition { it.value.age == 0 } + + val timeLockedSet = confirmed + .groupBy({ it.unlockTime }, { it.value.amount }) + .map { (unlockTime, amounts) -> TimeLocked(amounts.sum(), unlockTime) } + .toSet() + + return Balance(pending.sumOf { it.value.amount }, timeLockedSet) } diff --git a/lib/android/src/main/kotlin/im/molly/monero/Block.kt b/lib/android/src/main/kotlin/im/molly/monero/Block.kt new file mode 100644 index 0000000..3f3f21f --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/Block.kt @@ -0,0 +1,25 @@ +package im.molly.monero + +import im.molly.monero.internal.constants.CRYPTONOTE_MAX_BLOCK_NUMBER + +data class Block( + // TODO: val hash: HashDigest, + val header: BlockHeader, + val minerRewardTxIndex: Int, + val txs: Set, +) { + // TODO: val blockId: String get() = hash.toString() +} + +data class BlockHeader( + val height: Int, + val timestamp: Long, +) { + companion object { + const val MAX_HEIGHT = CRYPTONOTE_MAX_BLOCK_NUMBER - 1 + } +} + +fun isBlockHeightInRange(height: Long) = !(height < 0 || height > BlockHeader.MAX_HEIGHT) + +fun isBlockHeightInRange(height: Int) = isBlockHeightInRange(height.toLong()) diff --git a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt new file mode 100644 index 0000000..5cc3022 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -0,0 +1,108 @@ +package im.molly.monero + +import im.molly.monero.internal.constants.DIFFICULTY_TARGET_V2 +import java.time.Duration +import java.time.Instant +import java.time.LocalDate + + +/** + * A point in the blockchain timeline, which could be either a block height or a timestamp. + */ +open class BlockchainTime( + val height: Int, + val timestamp: Instant, +) : Comparable { + + init { + require(isBlockHeightInRange(height)) { + "Block height $height out of range" + } + } + + open fun toLong(): Long = height.toLong() + + override fun compareTo(other: BlockchainTime): Int = + this.height.compareTo(other.height) + + data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193)) + + class Block(height: Int, currentTime: BlockchainTime = Genesis) : + BlockchainTime(height, estimateTimestamp(height, currentTime)) + + class Timestamp(timestamp: Instant, currentTime: BlockchainTime = Genesis) : + BlockchainTime(estimateBlockHeight(timestamp, currentTime), timestamp) { + + constructor(date: LocalDate) : this(Instant.ofEpochSecond(date.toEpochDay())) + + override fun toLong() = timestamp.epochSecond.coerceAtLeast(BlockHeader.MAX_HEIGHT + 1L) + + override fun compareTo(other: BlockchainTime): Int = + this.timestamp.compareTo(other.timestamp) + } + + companion object { + val AVERAGE_BLOCK_TIME = Duration.ofSeconds(DIFFICULTY_TARGET_V2) + + fun estimateTimestamp(targetHeight: Int, currentTime: BlockchainTime): Instant { + require(targetHeight >= 0) { "Block height $targetHeight must not be negative" } + + return if (targetHeight == 0) { + Genesis.timestamp + } else { + val heightDiff = targetHeight - currentTime.height + val estTimeDiff = AVERAGE_BLOCK_TIME.multipliedBy(heightDiff.toLong()) + currentTime.timestamp.plus(estTimeDiff) + } + } + + fun estimateBlockHeight(targetTime: Instant, currentTime: BlockchainTime): Int { + val timeDiff = Duration.between(currentTime.timestamp, targetTime) + val estHeight = timeDiff.seconds / AVERAGE_BLOCK_TIME.seconds + currentTime.height + val clampedHeight = estHeight.coerceIn(0, BlockHeader.MAX_HEIGHT.toLong()) + return clampedHeight.toInt() + } + } + + fun fromUnlockTime(heightOrTimestamp: Long): BlockchainTime { + return if (isBlockHeightInRange(heightOrTimestamp)) { + Block(heightOrTimestamp.toInt(), currentTime = this) + } else { + val clampedTs = + if (heightOrTimestamp < 0 || heightOrTimestamp > Instant.MAX.epochSecond) Instant.MAX + else Instant.ofEpochSecond(heightOrTimestamp) + Timestamp(clampedTs, currentTime = this) + } + } + + fun until(endTime: BlockchainTime): BlockchainTimeSpan { + return BlockchainTimeSpan( + duration = Duration.between(timestamp, endTime.timestamp), + blocks = endTime.height - height, + ) + } + + operator fun minus(other: BlockchainTime): BlockchainTimeSpan = until(other) +} + +data class BlockchainTimeSpan(val duration: Duration, val blocks: Int) { + companion object { + val ZERO = BlockchainTimeSpan(duration = Duration.ZERO, blocks = 0) + } +} + +class TimeLocked(val value: T, val unlockTime: BlockchainTime) { + fun isLocked(currentTime: BlockchainTime): Boolean = currentTime < unlockTime + + fun getValueIfUnlocked(currentTime: BlockchainTime): T? { + return if (isLocked(currentTime)) null else value + } + + fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan { + return if (isLocked(currentTime)) { + unlockTime.minus(currentTime) + } else { + BlockchainTimeSpan.ZERO + } + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/Enote.kt b/lib/android/src/main/kotlin/im/molly/monero/Enote.kt new file mode 100644 index 0000000..0817cad --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/Enote.kt @@ -0,0 +1,14 @@ +package im.molly.monero + +data class Enote( + val amount: AtomicAmount, + val owner: AccountAddress, + val key: PublicKey, + val keyImage: HashDigest?, + val emissionTxId: String?, + val age: Int, +) { + init { + require(age >= 0) { "Enote age $age must not be negative" } + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/HashDigest.kt b/lib/android/src/main/kotlin/im/molly/monero/HashDigest.kt new file mode 100644 index 0000000..5de8023 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/HashDigest.kt @@ -0,0 +1,8 @@ +package im.molly.monero + +@JvmInline +value class HashDigest(private val hashDigest: String) { + init { + require(hashDigest.length == 32) { "Hash length must be 32 bytes" } + } +} 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 f06c59d..0445a57 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/Ledger.kt @@ -2,12 +2,13 @@ package im.molly.monero //import im.molly.monero.proto.LedgerProto -data class Ledger constructor( +data class Ledger( val publicAddress: String, - val receivedOutputs: List, - val checkedAtBlockHeight: Long, + val txs: Map, + val spendableEnotes: Set>, + val checkedAt: BlockchainTime, ) { - val balance = Balance.of(receivedOutputs) + val balance = spendableEnotes.balance() // 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 index b23e7ee..8c8646e 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroCurrency.kt @@ -1,5 +1,6 @@ package im.molly.monero +import im.molly.monero.internal.constants.CRYPTONOTE_DISPLAY_DECIMAL_POINT import java.math.BigDecimal import java.text.NumberFormat import java.util.* @@ -8,7 +9,7 @@ object MoneroCurrency { const val symbol = "XMR" fun format(atomicAmount: AtomicAmount, formatter: NumberFormat = DefaultFormatter): String = - formatter.format(BigDecimal.valueOf(atomicAmount.value, 12)) + formatter.format(BigDecimal.valueOf(atomicAmount.value, CRYPTONOTE_DISPLAY_DECIMAL_POINT)) fun parse(source: String, formatter: NumberFormat = DefaultFormatter): AtomicAmount { TODO() 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 d1e0a61..f2ef7c8 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -1,11 +1,14 @@ package im.molly.monero +import im.molly.monero.internal.TxInfo +import im.molly.monero.internal.consolidateTransactions import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.suspendCancellableCoroutine +import java.time.Instant class MoneroWallet internal constructor( private val wallet: IWallet, @@ -15,7 +18,7 @@ class MoneroWallet internal constructor( private val logger = loggerFor() - val primaryAddress: String = wallet.primaryAccountAddress + val primaryAddress: String = wallet.accountPrimaryAddress var dataStore by storageAdapter::dataStore @@ -26,13 +29,17 @@ class MoneroWallet internal constructor( val listener = object : IBalanceListener.Stub() { lateinit var lastKnownLedger: Ledger - override fun onBalanceChanged(txOuts: List?, checkedAtBlockHeight: Long) { - lastKnownLedger = Ledger(primaryAddress, txOuts!!, checkedAtBlockHeight) + override fun onBalanceChanged(txHistory: List, blockchainHeight: Int) { + val now = Instant.now() + val checkedAt = BlockchainTime(blockchainHeight, now) + val (txs, spendableEnotes) = txHistory.consolidateTransactions(checkedAt) + lastKnownLedger = Ledger(primaryAddress, txs, spendableEnotes, checkedAt) sendLedger(lastKnownLedger) } - override fun onRefresh(blockchainHeight: Long) { - sendLedger(lastKnownLedger.copy(checkedAtBlockHeight = blockchainHeight)) + override fun onRefresh(blockHeight: Int) { + val checkedAt = BlockchainTime.Block(blockHeight) + sendLedger(lastKnownLedger.copy(checkedAt = checkedAt)) } private fun sendLedger(ledger: Ledger) { @@ -52,7 +59,7 @@ class MoneroWallet internal constructor( skipCoinbaseOutputs: Boolean = false, ): RefreshResult = suspendCancellableCoroutine { continuation -> wallet.resumeRefresh(skipCoinbaseOutputs, object : BaseWalletCallbacks() { - override fun onRefreshResult(blockHeight: Long, status: Int) { + override fun onRefreshResult(blockHeight: Int, status: Int) { val result = RefreshResult(blockHeight, status) continuation.resume(result) {} } @@ -74,11 +81,11 @@ class MoneroWallet internal constructor( } private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() { - override fun onRefreshResult(blockHeight: Long, status: Int) = Unit + override fun onRefreshResult(blockHeight: Int, status: Int) = Unit override fun onCommitResult(success: Boolean) = Unit } -class RefreshResult(val blockHeight: Long, private val status: Int) { +class RefreshResult(val blockHeight: Int, private val status: Int) { fun isError() = status != WalletNative.Status.OK } diff --git a/lib/android/src/main/kotlin/im/molly/monero/ProtocolInfo.kt b/lib/android/src/main/kotlin/im/molly/monero/ProtocolInfo.kt new file mode 100644 index 0000000..ef50c29 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/ProtocolInfo.kt @@ -0,0 +1,3 @@ +package im.molly.monero + +enum class ProtocolInfo diff --git a/lib/android/src/main/kotlin/im/molly/monero/PublicKey.kt b/lib/android/src/main/kotlin/im/molly/monero/PublicKey.kt index 586454c..329dea3 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/PublicKey.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/PublicKey.kt @@ -1,3 +1,8 @@ package im.molly.monero -class PublicKey \ No newline at end of file +@JvmInline +value class PublicKey(private val publicKey: String) { + init { + require(publicKey.length == 64) { "Public key length must be 64 bytes" } + } +} diff --git a/lib/android/src/main/kotlin/im/molly/monero/RestorePoint.kt b/lib/android/src/main/kotlin/im/molly/monero/RestorePoint.kt deleted file mode 100644 index 7e272ec..0000000 --- a/lib/android/src/main/kotlin/im/molly/monero/RestorePoint.kt +++ /dev/null @@ -1,26 +0,0 @@ -package im.molly.monero - -import java.time.Instant -import java.time.LocalDate - -class RestorePoint { - val heightOrTimestamp: Long - - constructor() { - heightOrTimestamp = 0 - } - - constructor(blockHeight: Long) { - require(blockHeight >= 0) { "Block height cannot be negative" } - require(blockHeight < 500_000_000) { "Block height too large" } - heightOrTimestamp = blockHeight - } - - constructor(creationDate: LocalDate) { - heightOrTimestamp = creationDate.toEpochDay().coerceAtLeast(500_000_000) - } - - constructor(creationDate: Instant) { - heightOrTimestamp = creationDate.epochSecond.coerceAtLeast(500_000_000) - } -} diff --git a/lib/android/src/main/kotlin/im/molly/monero/SecretKey.kt b/lib/android/src/main/kotlin/im/molly/monero/SecretKey.kt index 553a888..1c02adf 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/SecretKey.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/SecretKey.kt @@ -12,6 +12,8 @@ import javax.security.auth.Destroyable * * SecretKey wraps a secret scalar value, helping to prevent accidental exposure and securely * erasing the value from memory. + * + * This class is not thread-safe. */ class SecretKey : Destroyable, Closeable, Parcelable { @@ -22,7 +24,7 @@ class SecretKey : Destroyable, Closeable, Parcelable { } constructor(secretScalar: ByteArray) { - require(secretScalar.size == 32) { "Secret key must be 256 bits" } + require(secretScalar.size == 32) { "Secret key must be 32 bytes" } secretScalar.copyInto(secret) } @@ -30,6 +32,9 @@ class SecretKey : Destroyable, Closeable, Parcelable { parcel.readByteArray(secret) } + internal val isNonZero + get() = !MessageDigest.isEqual(secret, ByteArray(secret.size)) + val bytes: ByteArray get() { check(!destroyed) { "Secret key has been already destroyed" } @@ -37,17 +42,14 @@ class SecretKey : Destroyable, Closeable, Parcelable { return secret.clone() } - val isNonZero - get() = !MessageDigest.isEqual(secret, ByteArray(secret.size)) - var destroyed = false private set override fun destroy() { if (!destroyed) { secret.fill(0) - destroyed = true } + destroyed = true } override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -68,16 +70,10 @@ class SecretKey : Destroyable, Closeable, Parcelable { } } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is SecretKey) return false + override fun equals(other: Any?): Boolean = + this === other || other is SecretKey && MessageDigest.isEqual(secret, other.secret) - return MessageDigest.isEqual(secret, other.secret) - } - - override fun hashCode(): Int { - return secret.contentHashCode() - } + override fun hashCode(): Int = secret.contentHashCode() override fun close() = destroy() diff --git a/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt new file mode 100644 index 0000000..da1c69e --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/Transaction.kt @@ -0,0 +1,44 @@ +package im.molly.monero + +data class Transaction( + val hash: HashDigest, + // TODO: val version: ProtocolInfo, + val state: TxState, + val timeLock: BlockchainTime, + val sent: Set, + val received: Set, + val payments: List, + val fee: AtomicAmount, + val change: AtomicAmount, +) { + val txId: String get() = hash.toString() + + val netAmount: AtomicAmount = calculateNetAmount() + + private fun calculateNetAmount(): AtomicAmount { + val receivedSum = received.sumOf { it.amount } + val sentSum = sent.sumOf { it.amount } + return receivedSum - sentSum + } +} + +sealed interface TxState { + val confirmed get() = this is OnChain + + data class OnChain( + val blockHeader: BlockHeader, + ) : TxState + + data object BeingProcessed : TxState + + data object InMemoryPool : TxState + + data object Failed : TxState + + data object OffChain : TxState +} + +data class PaymentDetail( + val amount: AtomicAmount, + val recipient: PublicAddress, +) 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 abed699..7466906 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -2,6 +2,8 @@ package im.molly.monero import android.os.ParcelFileDescriptor import androidx.annotation.GuardedBy +import im.molly.monero.internal.TxInfo +import im.molly.monero.internal.consolidateTransactions import kotlinx.coroutines.* import java.io.Closeable import java.util.* @@ -93,21 +95,21 @@ class WalletNative private constructor( } readFd.use { if (!nativeLoad(handle, it.fd)) { - throw IllegalStateException("Wallet data deserialization failed") + error("Wallet data deserialization failed") } } } } - override fun getPrimaryAccountAddress() = nativeGetPrimaryAccountAddress(handle) + override fun getAccountPrimaryAddress() = nativeGetAccountPrimaryAddress(handle) - val currentBlockchainHeight: Long + val currentBlockchainHeight: Int get() = nativeGetCurrentBlockchainHeight(handle) val currentBalance: Balance - get() = Balance.of(ownedTxOutsSnapshot()) + get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance() - private fun ownedTxOutsSnapshot(): List = nativeGetOwnedTxOuts(handle).toList() + private fun txHistorySnapshot(): List = nativeGetTxHistory(handle).toList() @GuardedBy("listenersLock") private val balanceListeners = mutableSetOf() @@ -161,7 +163,7 @@ class WalletNative private constructor( requireNotNull(listener) balanceListenersLock.withLock { balanceListeners.add(listener) - listener.onBalanceChanged(ownedTxOutsSnapshot(), currentBlockchainHeight) + listener.onBalanceChanged(txHistorySnapshot(), currentBlockchainHeight) } } @@ -173,13 +175,12 @@ class WalletNative private constructor( } @CalledByNative("wallet.cc") - private fun onRefresh(blockchainHeight: Long, balanceChanged: Boolean) { + private fun onRefresh(blockchainHeight: Int, balanceChanged: Boolean) { balanceListenersLock.withLock { if (balanceListeners.isNotEmpty()) { val call = fun(listener: IBalanceListener) { if (balanceChanged) { - val txOuts = ownedTxOutsSnapshot() - listener.onBalanceChanged(txOuts, blockchainHeight) + listener.onBalanceChanged(txHistorySnapshot(), blockchainHeight) } else { listener.onRefresh(blockchainHeight) } @@ -262,9 +263,10 @@ class WalletNative private constructor( private external fun nativeCancelRefresh(handle: Long) private external fun nativeCreate(networkId: Int): Long private external fun nativeDispose(handle: Long) - private external fun nativeGetCurrentBlockchainHeight(handle: Long): Long - private external fun nativeGetOwnedTxOuts(handle: Long): Array - private external fun nativeGetPrimaryAccountAddress(handle: Long): String + private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int + 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 nativeLoad(handle: Long, fd: Int): Boolean private external fun nativeNonReentrantRefresh(handle: Long, skipCoinbase: Boolean): Int private external fun nativeRestoreAccount( diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt index c3026cf..4d22ce6 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletProvider.kt @@ -69,7 +69,7 @@ class WalletProvider private constructor( dataStore: WalletDataStore? = null, client: RemoteNodeClient? = null, secretSpendKey: SecretKey, - restorePoint: RestorePoint, + restorePoint: BlockchainTime, ): MoneroWallet { require(client == null || client.network == network) val storageAdapter = StorageAdapter(dataStore) @@ -78,7 +78,7 @@ class WalletProvider private constructor( buildConfig(network), storageAdapter, client, WalletResultCallback(continuation), secretSpendKey, - restorePoint.heightOrTimestamp, + restorePoint.toLong(), ) } return MoneroWallet(wallet, storageAdapter, client) 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 new file mode 100644 index 0000000..67daee6 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt @@ -0,0 +1,153 @@ +package im.molly.monero.internal + +import android.os.Parcelable +import im.molly.monero.AccountAddress +import im.molly.monero.AtomicAmount +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.PaymentDetail +import im.molly.monero.PublicAddress +import im.molly.monero.PublicKey +import im.molly.monero.TimeLocked +import im.molly.monero.Transaction +import im.molly.monero.TxState +import kotlinx.parcelize.Parcelize + +/** + * TxInfo represents transaction information in a compact and easily serializable format. + * + * When grouping multiple `TxInfo` objects into a list, you can parse them into more structured + * objects such as [Block], [Transaction], and [Enote] to facilitate further processing of + * transaction history data. + */ +@Parcelize +internal data class TxInfo +@CalledByNative("wallet.cc") constructor( + val txHash: String, + val key: String, + val keyImage: String?, + val subAddressMajor: Int, + val subAddressMinor: Int, + val recipient: String?, + val amount: Long, + val height: Int, + val state: Int, + val unlockTime: Long, + val timestamp: Long, + val fee: Long, + val change: Long, + val coinbase: Boolean, + val incoming: Boolean, +) : Parcelable { + + val outgoing get() = !incoming + + companion object State { + const val OFF_CHAIN: Int = 0 + const val PENDING: Int = 1 + const val FAILED: Int = 2 + const val ON_CHAIN: Int = 3 + } + + init { + require(state in OFF_CHAIN..ON_CHAIN) + require(amount >= 0 && fee >= 0 && change >= 0) { + "TX amounts cannot be negative" + } + } +} + +internal fun List.consolidateTransactions( + blockchainTime: BlockchainTime, +): Pair, Set>> { + val enoteMap = mutableMapOf() + val keyImageMap = mutableMapOf() + val spentSet = mutableSetOf() + + forEach { txInfo -> + if (txInfo.incoming) { + enoteMap.computeIfAbsent(txInfo.key) { + txInfo.toEnote(blockchainTime.height).also { enote -> + txInfo.keyImage?.let { keyImageMap[it] = enote } + } + } + } else if (txInfo.keyImage != null) { + spentSet.add(txInfo.key) + } + } + + val groupedByTxHash = groupBy { it.txHash } + val txs = groupedByTxHash.mapValues { (txHash, infoList) -> + createTransaction(txHash, infoList, enoteMap, keyImageMap, blockchainTime) + } + + val spendableEnotes = enoteMap + .filterKeys { !spentSet.contains(it) } + .map { (_, enote) -> + TimeLocked(enote, txs[enote.emissionTxId]!!.timeLock) + } + .toSet() + + return txs to spendableEnotes +} + +private fun createTransaction( + txHash: String, + infoList: List, + enoteMap: Map, + keyImageMap: Map, + blockchainTime: BlockchainTime, +): Transaction { + val unlockTime = infoList.maxOf { it.unlockTime } + val fee = infoList.maxOf { it.fee } + val change = infoList.maxOf { it.change } + + val (ins, outs) = infoList.partition { it.incoming } + val received = ins.map { enoteMap.getValue(it.key) } + val spentKeyImages = outs.mapNotNull { it.keyImage }.toSet() + val sent = keyImageMap.filterKeys { it in spentKeyImages }.values + val payments = outs.map { it.toPaymentDetail() } + + return Transaction( + hash = HashDigest(txHash), + state = determineTxState(infoList), + timeLock = blockchainTime.fromUnlockTime(unlockTime), + sent = sent.toSet(), + received = received.toSet(), + payments = payments, + fee = AtomicAmount(fee), + change = AtomicAmount(change), + ) +} + +private fun determineTxState(infoList: List): TxState { + val txInfo = infoList.distinctBy { it.state }.single() + return when (txInfo.state) { + TxInfo.OFF_CHAIN -> TxState.OffChain + TxInfo.PENDING -> TxState.InMemoryPool + TxInfo.FAILED -> TxState.Failed + TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(txInfo.height, txInfo.timestamp)) + else -> throw IllegalArgumentException("Invalid tx state value: ${txInfo.state}") + } +} + +private fun TxInfo.toEnote(blockchainHeight: Int) = Enote( + amount = AtomicAmount(amount), + owner = AccountAddress( + publicAddress = PublicAddress.parse(recipient!!), + accountIndex = subAddressMajor, + subAddressIndex = subAddressMinor, + ), + key = PublicKey(key), + keyImage = keyImage?.let { HashDigest(it) }, + emissionTxId = txHash, + age = if (height == 0) 0 else (blockchainHeight - height + 1) +) + +private fun TxInfo.toPaymentDetail() = PaymentDetail( + amount = AtomicAmount(amount), + recipient = PublicAddress.parse(recipient!!), +) diff --git a/lib/android/src/main/kotlin/im/molly/monero/internal/constants/Constants.kt b/lib/android/src/main/kotlin/im/molly/monero/internal/constants/Constants.kt new file mode 100644 index 0000000..b619094 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/internal/constants/Constants.kt @@ -0,0 +1,6 @@ +package im.molly.monero.internal.constants + +internal const val CRYPTONOTE_DISPLAY_DECIMAL_POINT: Int = 12 +internal const val CRYPTONOTE_MAX_BLOCK_NUMBER: Int = 500_000_000 +internal const val CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE: Int = 10 +internal const val DIFFICULTY_TARGET_V2: Long = 120 diff --git a/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt b/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt index d72e5a1..6e5c2eb 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt @@ -61,9 +61,10 @@ object Decoder { out.position(newOutPos) } - private fun findOutputBlockSize(blockSize: Int): Int = blockSizes[blockSize].also { - require(it >= 0) { "Invalid block size" } - } + private fun findOutputBlockSize(blockSize: Int): Int = + blockSizes[blockSize].also { + require(it >= 0) { "Invalid block size" } + } } fun String.decodeBase58(): ByteArray = 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 index ff0a357..0d55ea8 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/util/HexFormat.kt @@ -7,3 +7,5 @@ fun CharSequence.parseHex(): ByteArray { Integer.parseInt(substring(it * 2, (it + 1) * 2), 16).toByte() } } + +fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) } diff --git a/lib/android/src/test/kotlin/im/molly/monero/SecretKeyTest.kt b/lib/android/src/test/kotlin/im/molly/monero/SecretKeyTest.kt index 70f2d91..db46b72 100644 --- a/lib/android/src/test/kotlin/im/molly/monero/SecretKeyTest.kt +++ b/lib/android/src/test/kotlin/im/molly/monero/SecretKeyTest.kt @@ -43,11 +43,10 @@ class SecretKeyTest { @Test fun `two keys with same secret are the same`() { val secret = Random.nextBytes(32) - val anotherSecret = Random.nextBytes(32) val key = SecretKey(secret) val sameKey = SecretKey(secret) - val anotherKey = SecretKey(anotherSecret) + val anotherKey = randomSecretKey() assertThat(key).isEqualTo(sameKey) assertThat(sameKey).isNotEqualTo(anotherKey) @@ -61,4 +60,22 @@ class SecretKeyTest { assertThat(randomKeys).hasSize(times) } + + @Test + fun `keys are not equal to their destroyed versions`() { + val secret = Random.nextBytes(32) + + val key = SecretKey(secret) + val destroyed = SecretKey(secret).also { it.destroy() } + + assertThat(key).isNotEqualTo(destroyed) + } + + @Test + fun `destroyed keys are equal`() { + val destroyed = randomSecretKey().also { it.destroy() } + val anotherDestroyed = randomSecretKey().also { it.destroy() } + + assertThat(destroyed).isEqualTo(anotherDestroyed) + } }