lib: fix transaction timestamps

This commit is contained in:
Oscar Mira 2023-10-23 12:12:11 +02:00
parent f5998ac369
commit 2bd227124a
13 changed files with 126 additions and 86 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package im.molly.monero;
parcelable BlockchainTime;

View File

@ -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<TxInfo> txHistory, int blockchainHeight);
void onRefresh(int blockchainHeight);
void onBalanceChanged(in List<TxInfo> txHistory, in BlockchainTime blockchainTime);
void onRefresh(in BlockchainTime blockchainTime);
}

View File

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

View File

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

View File

@ -36,7 +36,8 @@ Wallet::Wallet(
std::make_unique<RemoteNodeClientFactory>(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<std::pair<crypto::hash, payment_details>> pds,
const transfer_details& td) {
if (td.m_txid == crypto::null_hash) {
const payment_details* find_payment_by_txid(
const std::list<std::pair<crypto::hash, payment_details>>& 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<std::pair<crypto::hash, confirmed_transfer_details>> 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<std::pair<crypto::hash, confirmed_transfer_details>>& 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<TxInfo>& 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<TxInfo>& 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<Wallet*>(handle);
return wallet->current_blockchain_timestamp();
}
ScopedJvmLocalRef<jobject> nativeToJvmTxInfo(JNIEnv* env,
const TxInfo& tx) {
LOG_FATAL_IF(tx.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER, "Blockchain max height reached");

View File

@ -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<uint32_t>(m_blockchain_height); }
uint32_t current_blockchain_height() const { return static_cast<uint32_t>(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<TxInfo> 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<typename T>
auto suspendRefreshAndRunLocked(T block) -> decltype(block());
void captureTxHistorySnapshot(std::vector<TxInfo>& 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 {

View File

@ -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<BlockchainTime> {
) : Comparable<BlockchainTime>, 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 {

View File

@ -29,17 +29,14 @@ class MoneroWallet internal constructor(
val listener = object : IBalanceListener.Stub() {
lateinit var lastKnownLedger: Ledger
override fun onBalanceChanged(txHistory: List<TxInfo>, 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<TxInfo>, 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
}

View File

@ -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<TxInfo>
private external fun nativeGetAccountPrimaryAddress(handle: Long): String
// private external fun nativeGetAccountSubAddress(handle: Long, accountIndex: Int, subAddressIndex: Int): String

View File

@ -83,7 +83,7 @@ internal fun List<TxInfo>.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<TxInfo>.createTransaction(
}
private fun List<TxInfo>.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),