diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionCard.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionCard.kt index 99fd908..b55c7a6 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionCard.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/TransactionCard.kt @@ -49,7 +49,7 @@ fun TransactionCardExpanded( localDateTime.format(formatter) } Text( - text = timestamp ?: "", + text = timestamp ?: "Pending", style = MaterialTheme.typography.titleLarge, ) Text( @@ -64,7 +64,7 @@ fun TransactionCardExpanded( modifier = modifier.fillMaxWidth(), ) { Text( - text = "#${transaction.blockHeight}", + text = transaction.blockHeight?.toString() ?: "", style = MaterialTheme.typography.titleSmall, ) Text( diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt index 6b7b88e..4cd79b4 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt @@ -54,7 +54,7 @@ fun WalletBalanceView( ) { Text( style = MaterialTheme.typography.bodyLarge, - text = "Balance at Block #${blockchainTime.height}", + text = "Balance at ${blockchainTime}", ) Spacer(modifier = Modifier.height(12.dp)) 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 1207455..39b3490 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 @@ -15,6 +15,7 @@ import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletTransaction import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.time.Instant class WalletViewModel( walletId: Long, @@ -65,7 +66,7 @@ private fun walletUiState( val transactions = ledger.transactions .map { WalletTransaction(config.id, it.value) } - .sortedByDescending { it.transaction.timestamp } + .sortedByDescending { it.transaction.timestamp ?: Instant.MAX } val network = ledger.primaryAddress.network WalletUiState.Loaded(config, network, blockchainTime, balance, transactions) } diff --git a/lib/android/src/main/aidl/im/molly/monero/BlockchainTime.aidl b/lib/android/src/main/aidl/im/molly/monero/BlockchainTime.aidl new file mode 100644 index 0000000..3615794 --- /dev/null +++ b/lib/android/src/main/aidl/im/molly/monero/BlockchainTime.aidl @@ -0,0 +1,3 @@ +package im.molly.monero; + +parcelable BlockchainTime; 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 2a0b65f..075fa1b 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,9 @@ package im.molly.monero; +import im.molly.monero.BlockchainTime; import im.molly.monero.internal.TxInfo; oneway interface IBalanceListener { - void onBalanceChanged(in List txHistory, int blockchainHeight); - void onRefresh(int blockchainHeight); + void onBalanceChanged(in List txHistory, in BlockchainTime blockchainTime); + void onRefresh(in BlockchainTime blockchainTime); } 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 33c2073..a106a81 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,8 @@ package im.molly.monero; +import im.molly.monero.BlockchainTime; + oneway interface IWalletCallbacks { - void onRefreshResult(int blockHeight, int status); + void onRefreshResult(in BlockchainTime blockchainTime, int status); void onCommitResult(boolean success); } diff --git a/lib/android/src/main/cpp/jni_cache.cc b/lib/android/src/main/cpp/jni_cache.cc index 6a41d8d..5c9a465 100644 --- a/lib/android/src/main/cpp/jni_cache.cc +++ b/lib/android/src/main/cpp/jni_cache.cc @@ -40,7 +40,7 @@ void initializeJniCache(JNIEnv* env) { "callRemoteNode", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B)Lim/molly/monero/HttpResponse;"); WalletNative_onRefresh = walletNative - .getMethodId(env, "onRefresh", "(IZ)V"); + .getMethodId(env, "onRefresh", "(IJZ)V"); WalletNative_onSuspendRefresh = walletNative .getMethodId(env, "onSuspendRefresh", "(Z)V"); diff --git a/lib/android/src/main/cpp/wallet.cc b/lib/android/src/main/cpp/wallet.cc index 9ec4ea5..cb84ef7 100644 --- a/lib/android/src/main/cpp/wallet.cc +++ b/lib/android/src/main/cpp/wallet.cc @@ -36,7 +36,8 @@ Wallet::Wallet( std::make_unique(env, wallet_native)), m_callback(env, wallet_native), m_account_ready(false), - m_blockchain_height(1), + m_last_block_height(1), + m_last_block_timestamp(1397818193), m_restore_height(0), m_refresh_running(false), m_refresh_canceled(false) { @@ -97,7 +98,6 @@ bool Wallet::parseFrom(std::istream& input) { return false; if (!serialization::serialize(ar, m_wallet)) return false; - set_current_blockchain_height(m_wallet.get_blockchain_current_height()); captureTxHistorySnapshot(m_tx_history); m_account_ready = true; return true; @@ -127,49 +127,40 @@ std::string Wallet::public_address() const { return account.get_public_address_str(m_wallet.nettype()); } -void Wallet::set_current_blockchain_height(uint64_t height) { - LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached"); - m_blockchain_height = height; -} - cryptonote::account_base& Wallet::require_account() { LOG_FATAL_IF(!m_account_ready, "Account is not initialized"); return m_wallet.get_account(); } -const payment_details* find_matching_payment( - const std::list> pds, - const transfer_details& td) { - if (td.m_txid == crypto::null_hash) { +const payment_details* find_payment_by_txid( + const std::list>& pds, + const crypto::hash& txid) { + if (txid == crypto::null_hash) { return nullptr; } - for (const auto& p: pds) { - const auto& pd = p.second; - if (td.m_amount == pd.m_amount - && td.m_subaddr_index == pd.m_subaddr_index - && td.m_txid == pd.m_tx_hash) { - return &pd; + for (auto it = pds.begin(); it != pds.end(); ++it) { + const auto pd = &it->second; + if (txid == pd->m_tx_hash) { + return pd; } } return nullptr; -}; +} -const confirmed_transfer_details* find_matching_transfer_for_change( - const std::list> txs, - const transfer_details& td) { - if (td.m_txid == crypto::null_hash || td.m_subaddr_index.minor != 0) { +const confirmed_transfer_details* find_transfer_by_txid( + const std::list>& txs, + const crypto::hash& txid) { + if (txid == crypto::null_hash) { return nullptr; } - for (const auto& p: txs) { - const auto& tx = p.second; - if (td.m_amount == tx.m_change - && td.m_subaddr_index.major == tx.m_subaddr_account - && td.m_txid == p.first) { - return &tx; + for (auto it = txs.begin(); it != txs.end(); ++it) { + const auto tx = &it->second; + if (txid == it->first) { + return tx; } } return nullptr; -}; +} // Only call this function from the callback thread or during initialization, // as there is no locking mechanism to safeguard reading transaction history @@ -205,14 +196,14 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { recv.m_amount = td.m_amount; recv.m_unlock_time = td.m_tx.unlock_time; - // Check if the payment or change exists and update metadata if found. - if (const auto* pd = find_matching_payment(pds, td)) { + // Check if the payment or transfer exists and update metadata if found. + if (const auto* pd = find_payment_by_txid(pds, td.m_txid)) { 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 if (const auto tx = find_matching_transfer_for_change(txs, td)) { + } else if (const auto* tx = find_transfer_by_txid(txs, td.m_txid)) { recv.m_height = tx->m_block_height; recv.m_timestamp = tx->m_timestamp; recv.m_fee = tx->m_amount_in - tx->m_amount_out; @@ -336,15 +327,11 @@ void Wallet::captureTxHistorySnapshot(std::vector& snapshot) { } } -void Wallet::handleNewBlock(uint64_t height, bool refresh_running) { - set_current_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::handleNewBlock(uint64_t height, uint64_t timestamp) { + LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached"); + m_last_block_height = height; + m_last_block_timestamp = timestamp; + processBalanceChanges(true); } void Wallet::handleReorgEvent(uint64_t at_block_height) { @@ -355,11 +342,22 @@ void Wallet::handleMoneyEvent(uint64_t at_block_height) { m_balance_changed = true; } -void Wallet::notifyRefresh(bool debounce) { +void Wallet::processBalanceChanges(bool refresh_running) { + if (m_balance_changed) { + m_tx_history_mutex.lock(); + captureTxHistorySnapshot(m_tx_history); + m_tx_history_mutex.unlock(); + } + notifyRefreshState(!m_balance_changed && refresh_running); + m_balance_changed = false; +} + +void Wallet::notifyRefreshState(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. uint32_t height = current_blockchain_height(); + uint64_t ts = current_blockchain_timestamp(); if (debounce) { if (height % 100 == 0) { auto now = std::chrono::steady_clock::now(); @@ -373,7 +371,7 @@ void Wallet::notifyRefresh(bool debounce) { } if (!debounce) { m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh, - height, m_balance_changed); + height, ts, m_balance_changed); } } @@ -409,7 +407,7 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) { } m_refresh_running.store(false); // Ensure the latest block and pool state are consistently processed. - handleNewBlock(m_wallet.get_blockchain_current_height(), false); + processBalanceChanges(false); return ret; } @@ -583,6 +581,16 @@ Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight( return wallet->current_blockchain_height(); } +extern "C" +JNIEXPORT jlong JNICALL +Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainTimestamp( + JNIEnv* env, + jobject thiz, + jlong handle) { + auto* wallet = reinterpret_cast(handle); + return wallet->current_blockchain_timestamp(); +} + ScopedJvmLocalRef nativeToJvmTxInfo(JNIEnv* env, const TxInfo& tx) { LOG_FATAL_IF(tx.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached"); diff --git a/lib/android/src/main/cpp/wallet.h b/lib/android/src/main/cpp/wallet.h index 789a21d..463ee42 100644 --- a/lib/android/src/main/cpp/wallet.h +++ b/lib/android/src/main/cpp/wallet.h @@ -97,13 +97,15 @@ class Wallet : tools::i_wallet2_callback { std::string public_address() const; - void set_current_blockchain_height(uint64_t height); - uint32_t current_blockchain_height() const { return static_cast(m_blockchain_height); } + uint32_t current_blockchain_height() const { return static_cast(m_last_block_height); } + uint64_t current_blockchain_timestamp() const { return m_last_block_timestamp; } // Extra state that must be persistent but isn't restored by wallet2's serializer. BEGIN_SERIALIZE_OBJECT() VERSION_FIELD(0) FIELD(m_restore_height) + FIELD(m_last_block_height) + FIELD(m_last_block_timestamp) END_SERIALIZE() private: @@ -113,7 +115,8 @@ class Wallet : tools::i_wallet2_callback { bool m_account_ready; uint64_t m_restore_height; - uint64_t m_blockchain_height; + uint64_t m_last_block_height; + uint64_t m_last_block_timestamp; // Saved transaction history. std::vector m_tx_history; @@ -131,20 +134,22 @@ class Wallet : tools::i_wallet2_callback { bool m_refresh_canceled; bool m_balance_changed; - void notifyRefresh(bool debounce); + void processBalanceChanges(bool refresh_running); + void notifyRefreshState(bool debounce); template auto suspendRefreshAndRunLocked(T block) -> decltype(block()); void captureTxHistorySnapshot(std::vector& snapshot); - void handleNewBlock(uint64_t height, bool refresh_running); + void handleNewBlock(uint64_t height, uint64_t timestmap); 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, true); + // Block could be empty during a fast refresh. + handleNewBlock(height, block.timestamp); } void on_reorg(uint64_t height) override { diff --git a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt index 17b8326..4d704f0 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -1,6 +1,8 @@ package im.molly.monero +import android.os.Parcelable import im.molly.monero.internal.constants.DIFFICULTY_TARGET_V2 +import kotlinx.parcelize.Parcelize import java.time.Duration import java.time.Instant import java.time.LocalDate @@ -9,10 +11,11 @@ import java.time.LocalDate /** * A point in the blockchain timeline, which could be either a block height or a timestamp. */ +@Parcelize open class BlockchainTime( val height: Int, val timestamp: Instant, -) : Comparable { +) : Comparable, Parcelable { init { require(isBlockHeightInRange(height)) { @@ -25,12 +28,15 @@ open class BlockchainTime( override fun compareTo(other: BlockchainTime): Int = this.height.compareTo(other.height) - override fun toString(): String = "Block #$height | $timestamp" + override fun toString(): String = "Block $height | Time $timestamp" data object Genesis : BlockchainTime(0, Instant.ofEpochSecond(1397818193)) class Block(height: Int, referencePoint: BlockchainTime = Genesis) : - BlockchainTime(height, estimateTimestamp(height, referencePoint)) {} + BlockchainTime(height, estimateTimestamp(height, referencePoint)) { + + override fun toString(): String = "Block $height | Time $timestamp (Estimated)" + } class Timestamp(timestamp: Instant, referencePoint: BlockchainTime = Genesis) : BlockchainTime(estimateBlockHeight(timestamp, referencePoint), timestamp) { @@ -41,6 +47,8 @@ open class BlockchainTime( override fun compareTo(other: BlockchainTime): Int = this.timestamp.compareTo(other.timestamp) + + override fun toString(): String = "Block $height (Estimated) | Time $timestamp" } companion object { 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 c5f4c8a..fb1743b 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -29,17 +29,14 @@ class MoneroWallet internal constructor( val listener = object : IBalanceListener.Stub() { lateinit var lastKnownLedger: Ledger - 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) + override fun onBalanceChanged(txHistory: List, blockchainTime: BlockchainTime) { + val (txs, spendableEnotes) = txHistory.consolidateTransactions(blockchainTime) + lastKnownLedger = Ledger(primaryAddress, txs, spendableEnotes, blockchainTime) sendLedger(lastKnownLedger) } - override fun onRefresh(blockHeight: Int) { - val checkedAt = BlockchainTime.Block(blockHeight) - sendLedger(lastKnownLedger.copy(checkedAt = checkedAt)) + override fun onRefresh(blockchainTime: BlockchainTime) { + sendLedger(lastKnownLedger.copy(checkedAt = blockchainTime)) } private fun sendLedger(ledger: Ledger) { @@ -59,8 +56,8 @@ class MoneroWallet internal constructor( skipCoinbaseOutputs: Boolean = false, ): RefreshResult = suspendCancellableCoroutine { continuation -> wallet.resumeRefresh(skipCoinbaseOutputs, object : BaseWalletCallbacks() { - override fun onRefreshResult(blockHeight: Int, status: Int) { - val result = RefreshResult(blockHeight, status) + override fun onRefreshResult(blockchainTime: BlockchainTime, status: Int) { + val result = RefreshResult(blockchainTime, status) continuation.resume(result) {} } }) @@ -81,11 +78,11 @@ class MoneroWallet internal constructor( } private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() { - override fun onRefreshResult(blockHeight: Int, status: Int) = Unit + override fun onRefreshResult(blockchainTime: BlockchainTime, status: Int) = Unit override fun onCommitResult(success: Boolean) = Unit } -class RefreshResult(val blockHeight: Int, private val status: Int) { +class RefreshResult(val blockchainTime: BlockchainTime, private val status: Int) { fun isError() = status != WalletNative.Status.OK } 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 7466906..c3c2f1b 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -6,6 +6,7 @@ import im.molly.monero.internal.TxInfo import im.molly.monero.internal.consolidateTransactions import kotlinx.coroutines.* import java.io.Closeable +import java.time.Instant import java.util.* import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -103,8 +104,19 @@ class WalletNative private constructor( override fun getAccountPrimaryAddress() = nativeGetAccountPrimaryAddress(handle) - val currentBlockchainHeight: Int - get() = nativeGetCurrentBlockchainHeight(handle) + private fun createBlockchainTime(height: Int, epochSeconds: Long): BlockchainTime { + return if (epochSeconds == 0L) { + BlockchainTime.Block(height) + } else { + BlockchainTime(height, Instant.ofEpochSecond(epochSeconds)) + } + } + + val currentBlockchainTime: BlockchainTime + get() = createBlockchainTime( + nativeGetCurrentBlockchainHeight(handle), + nativeGetCurrentBlockchainTimestamp(handle), + ) val currentBalance: Balance get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance() @@ -133,7 +145,7 @@ class WalletNative private constructor( nativeCancelRefresh(handle) } } - callback?.onRefreshResult(currentBlockchainHeight, status) + callback?.onRefreshResult(currentBlockchainTime, status) } } @@ -163,7 +175,7 @@ class WalletNative private constructor( requireNotNull(listener) balanceListenersLock.withLock { balanceListeners.add(listener) - listener.onBalanceChanged(txHistorySnapshot(), currentBlockchainHeight) + listener.onBalanceChanged(txHistorySnapshot(), currentBlockchainTime) } } @@ -175,14 +187,15 @@ class WalletNative private constructor( } @CalledByNative("wallet.cc") - private fun onRefresh(blockchainHeight: Int, balanceChanged: Boolean) { + private fun onRefresh(height: Int, timestamp: Long, balanceChanged: Boolean) { balanceListenersLock.withLock { if (balanceListeners.isNotEmpty()) { val call = fun(listener: IBalanceListener) { + val blockchainTime = createBlockchainTime(height, timestamp) if (balanceChanged) { - listener.onBalanceChanged(txHistorySnapshot(), blockchainHeight) + listener.onBalanceChanged(txHistorySnapshot(), blockchainTime) } else { - listener.onRefresh(blockchainHeight) + listener.onRefresh(blockchainTime) } } balanceListeners.forEach { call(it) } @@ -264,6 +277,7 @@ class WalletNative private constructor( private external fun nativeCreate(networkId: Int): Long private external fun nativeDispose(handle: Long) private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int + private external fun nativeGetCurrentBlockchainTimestamp(handle: Long): Long 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 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 a701107..1dedb65 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 @@ -83,7 +83,7 @@ internal fun List.consolidateTransactions( if (tx.state !is TxState.Failed) { val maxUnlockTime = tx.blockHeight?.let { height -> val defaultUnlockTime = BlockchainTime.Block( - height = height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE, + height = height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE - 1, referencePoint = blockchainContext, ) max(defaultUnlockTime, tx.timeLock ?: BlockchainTime.Genesis) @@ -139,14 +139,15 @@ private fun List.createTransaction( } private fun List.determineTxState(): TxState { - val uniqueTx = distinctBy { it.state }.single() + val height = maxOf { it.height } + val timestamp = maxOf { it.timestamp } - return when (uniqueTx.state) { + return when (val state = first().state) { TxInfo.OFF_CHAIN -> TxState.OffChain TxInfo.PENDING -> TxState.InMemoryPool TxInfo.FAILED -> TxState.Failed - TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(uniqueTx.height, uniqueTx.timestamp)) - else -> error("Invalid tx state value: ${uniqueTx.state}") + TxInfo.ON_CHAIN -> TxState.OnChain(BlockHeader(height, timestamp)) + else -> error("Invalid tx state value: $state") } } @@ -157,7 +158,7 @@ private fun TxInfo.toEnote(blockchainHeight: Int): Enote { subAddressIndex = subAddressMinor ) - val calculatedAge = if (height == 0) 0 else blockchainHeight - height + 1 + val calculatedAge = if (height > 0) (blockchainHeight - height + 1) else 0 return Enote( amount = MoneroAmount(atomicUnits = amount),