lib: transaction history processing

This commit is contained in:
Oscar Mira 2023-10-11 00:52:55 +02:00
parent acfb1aca5e
commit 48fb61d1b3
35 changed files with 865 additions and 196 deletions

View File

@ -32,7 +32,7 @@ class MoneroSdkClient(private val context: Context) {
network: MoneroNetwork, network: MoneroNetwork,
filename: String, filename: String,
secretSpendKey: SecretKey, secretSpendKey: SecretKey,
restorePoint: RestorePoint, restorePoint: BlockchainTime,
): MoneroWallet { ): MoneroWallet {
val provider = providerDeferred.await() val provider = providerDeferred.await()
return provider.restoreWallet( return provider.restoreWallet(

View File

@ -79,7 +79,7 @@ class WalletRepository(
name: String, name: String,
remoteNodeIds: List<Long>, remoteNodeIds: List<Long>,
secretSpendKey: SecretKey, secretSpendKey: SecretKey,
restorePoint: RestorePoint, restorePoint: BlockchainTime,
): Pair<Long, MoneroWallet> { ): Pair<Long, MoneroWallet> {
val uniqueFilename = UUID.randomUUID().toString() val uniqueFilename = UUID.randomUUID().toString()
val wallet = moneroSdkClient.restoreWallet( val wallet = moneroSdkClient.restoreWallet(

View File

@ -4,7 +4,7 @@ import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import im.molly.monero.MoneroNetwork import im.molly.monero.MoneroNetwork
import im.molly.monero.RestorePoint import im.molly.monero.BlockchainTime
import im.molly.monero.SecretKey import im.molly.monero.SecretKey
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.RemoteNodeRepository import im.molly.monero.demo.data.RemoteNodeRepository
@ -96,19 +96,17 @@ class AddWalletViewModel(
creationDate.isEmpty() || runCatching { LocalDate.parse(creationDate) }.isSuccess creationDate.isEmpty() || runCatching { LocalDate.parse(creationDate) }.isSuccess
fun validateRestoreHeight(): Boolean = fun validateRestoreHeight(): Boolean =
restoreHeight.isEmpty() || runCatching { RestorePoint(restoreHeight.toLong()) }.isSuccess restoreHeight.isEmpty() || runCatching { BlockchainTime.Block(restoreHeight.toInt()) }.isSuccess
fun createWallet() = viewModelScope.launch { fun createWallet() = viewModelScope.launch {
walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds()) walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds())
} }
fun restoreWallet() = viewModelScope.launch { fun restoreWallet() = viewModelScope.launch {
val restorePoint = if (creationDate.isNotEmpty()) { val restorePoint = when {
RestorePoint(creationDate = LocalDate.parse(creationDate)) creationDate.isNotEmpty() -> BlockchainTime.Timestamp(LocalDate.parse(creationDate))
} else if (restoreHeight.isNotEmpty()) { restoreHeight.isNotEmpty() -> BlockchainTime.Block(restoreHeight.toInt())
RestorePoint(blockHeight = restoreHeight.toLong()) else -> BlockchainTime.Genesis
} else {
RestorePoint(blockHeight = 0)
} }
SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey -> SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey ->
walletRepository.restoreWallet( walletRepository.restoreWallet(

View File

@ -112,7 +112,7 @@ private fun WalletScreenPopulated(
text = buildAnnotatedString { text = buildAnnotatedString {
append(MoneroCurrency.symbol + " ") append(MoneroCurrency.symbol + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(MoneroCurrency.format(ledger.balance.totalAmount)) append(MoneroCurrency.format(ledger.balance.confirmedBalance))
} }
} }
) )

View File

@ -1,8 +1,8 @@
package im.molly.monero; package im.molly.monero;
import im.molly.monero.OwnedTxOut; import im.molly.monero.internal.TxInfo;
oneway interface IBalanceListener { oneway interface IBalanceListener {
void onBalanceChanged(in List<OwnedTxOut> txOuts, long checkedAtBlockHeight); void onBalanceChanged(in List<TxInfo> txHistory, int blockchainHeight);
void onRefresh(long blockchainHeight); void onRefresh(int blockchainHeight);
} }

View File

@ -4,7 +4,7 @@ import im.molly.monero.IBalanceListener;
import im.molly.monero.IWalletCallbacks; import im.molly.monero.IWalletCallbacks;
interface IWallet { interface IWallet {
String getPrimaryAccountAddress(); String getAccountPrimaryAddress();
void addBalanceListener(in IBalanceListener listener); void addBalanceListener(in IBalanceListener listener);
void removeBalanceListener(in IBalanceListener listener); void removeBalanceListener(in IBalanceListener listener);
oneway void resumeRefresh(boolean skipCoinbaseOutputs, in IWalletCallbacks callback); oneway void resumeRefresh(boolean skipCoinbaseOutputs, in IWalletCallbacks callback);

View File

@ -1,6 +1,6 @@
package im.molly.monero; package im.molly.monero;
oneway interface IWalletCallbacks { oneway interface IWalletCallbacks {
void onRefreshResult(long blockHeight, int status); void onRefreshResult(int blockHeight, int status);
void onCommitResult(boolean success); void onCommitResult(boolean success);
} }

View File

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

View File

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

View File

@ -3,12 +3,12 @@
namespace monero { namespace monero {
// im.molly.monero // im.molly.monero
ScopedJvmGlobalRef<jclass> OwnedTxOut; ScopedJvmGlobalRef<jclass> TxInfoClass;
jmethodID HttpResponse_getBody; jmethodID HttpResponse_getBody;
jmethodID HttpResponse_getCode; jmethodID HttpResponse_getCode;
jmethodID HttpResponse_getContentType; jmethodID HttpResponse_getContentType;
jmethodID Logger_logFromNative; jmethodID Logger_logFromNative;
jmethodID OwnedTxOut_ctor; jmethodID TxInfo_ctor;
jmethodID WalletNative_callRemoteNode; jmethodID WalletNative_callRemoteNode;
jmethodID WalletNative_onRefresh; jmethodID WalletNative_onRefresh;
jmethodID WalletNative_onSuspendRefresh; jmethodID WalletNative_onSuspendRefresh;
@ -20,9 +20,11 @@ void initializeJniCache(JNIEnv* env) {
// im.molly.monero // im.molly.monero
auto httpResponse = findClass(env, "im/molly/monero/HttpResponse"); auto httpResponse = findClass(env, "im/molly/monero/HttpResponse");
auto logger = findClass(env, "im/molly/monero/Logger"); 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"); auto walletNative = findClass(env, "im/molly/monero/WalletNative");
TxInfoClass = txInfoClass;
HttpResponse_getBody = httpResponse HttpResponse_getBody = httpResponse
.getMethodId(env, "getBody", "()Landroid/os/ParcelFileDescriptor;"); .getMethodId(env, "getBody", "()Landroid/os/ParcelFileDescriptor;");
HttpResponse_getCode = httpResponse HttpResponse_getCode = httpResponse
@ -31,8 +33,8 @@ void initializeJniCache(JNIEnv* env) {
.getMethodId(env, "getContentType", "()Ljava/lang/String;"); .getMethodId(env, "getContentType", "()Ljava/lang/String;");
Logger_logFromNative = logger Logger_logFromNative = logger
.getMethodId(env, "logFromNative", "(ILjava/lang/String;Ljava/lang/String;)V"); .getMethodId(env, "logFromNative", "(ILjava/lang/String;Ljava/lang/String;)V");
OwnedTxOut_ctor = ownedTxOut TxInfo_ctor = txInfoClass
.getMethodId(env, "<init>", "([BJJJ)V"); .getMethodId(env, "<init>", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;JIIJJJJZZ)V");
WalletNative_callRemoteNode = walletNative WalletNative_callRemoteNode = walletNative
.getMethodId(env, .getMethodId(env,
"callRemoteNode", "callRemoteNode",
@ -42,8 +44,6 @@ void initializeJniCache(JNIEnv* env) {
WalletNative_onSuspendRefresh = walletNative WalletNative_onSuspendRefresh = walletNative
.getMethodId(env, "onSuspendRefresh", "(Z)V"); .getMethodId(env, "onSuspendRefresh", "(Z)V");
OwnedTxOut = ownedTxOut;
// android.os // android.os
auto parcelFileDescriptor = findClass(env, "android/os/ParcelFileDescriptor"); auto parcelFileDescriptor = findClass(env, "android/os/ParcelFileDescriptor");

View File

@ -9,12 +9,12 @@ namespace monero {
void initializeJniCache(JNIEnv* env); void initializeJniCache(JNIEnv* env);
// im.molly.monero // im.molly.monero
extern ScopedJvmGlobalRef<jclass> OwnedTxOut; extern ScopedJvmGlobalRef<jclass> TxInfoClass;
extern jmethodID HttpResponse_getBody; extern jmethodID HttpResponse_getBody;
extern jmethodID HttpResponse_getCode; extern jmethodID HttpResponse_getCode;
extern jmethodID HttpResponse_getContentType; extern jmethodID HttpResponse_getContentType;
extern jmethodID Logger_logFromNative; extern jmethodID Logger_logFromNative;
extern jmethodID OwnedTxOut_ctor; extern jmethodID TxInfo_ctor;
extern jmethodID WalletNative_callRemoteNode; extern jmethodID WalletNative_callRemoteNode;
extern jmethodID WalletNative_onRefresh; extern jmethodID WalletNative_onRefresh;
extern jmethodID WalletNative_onSuspendRefresh; extern jmethodID WalletNative_onSuspendRefresh;

View File

@ -11,15 +11,20 @@
#include "eraser.h" #include "eraser.h"
#include "fd.h" #include "fd.h"
#include "string_tools.h"
namespace io = boost::iostreams; namespace io = boost::iostreams;
namespace monero { namespace monero {
using namespace std::chrono_literals; using namespace std::chrono_literals;
using namespace epee::string_tools;
static_assert(COIN == 1e12, "Monero atomic unit must be 1e-12 XMR"); static_assert(COIN == 1e12, "Monero atomic unit must be 1e-12 XMR");
static_assert(CRYPTONOTE_MAX_BLOCK_NUMBER == 500000000, static_assert(CRYPTONOTE_MAX_BLOCK_NUMBER == 500000000,
"Min timestamp must be higher than max block height"); "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( Wallet::Wallet(
JNIEnv* env, JNIEnv* env,
@ -93,7 +98,7 @@ bool Wallet::parseFrom(std::istream& input) {
if (!serialization::serialize(ar, m_wallet)) if (!serialization::serialize(ar, m_wallet))
return false; return false;
m_blockchain_height = m_wallet.get_blockchain_current_height(); m_blockchain_height = m_wallet.get_blockchain_current_height();
m_wallet.get_transfers(m_tx_outs); captureTxHistorySnapshot(m_tx_history);
m_account_ready = true; m_account_ready = true;
return true; return true;
} }
@ -111,10 +116,10 @@ bool Wallet::writeTo(std::ostream& output) {
}); });
} }
template<typename Callback> template<typename Consumer>
void Wallet::getOwnedTxOuts(Callback callback) { void Wallet::withTxHistory(Consumer consumer) {
std::lock_guard<std::mutex> lock(m_tx_outs_mutex); std::lock_guard<std::mutex> lock(m_tx_history_mutex);
callback(m_tx_outs); consumer(m_tx_history);
} }
std::string Wallet::public_address() const { std::string Wallet::public_address() const {
@ -127,38 +132,230 @@ cryptonote::account_base& Wallet::require_account() {
return m_wallet.get_account(); return m_wallet.get_account();
} }
// Reading m_transfers from wallet2 is not guarded by any lock; call this function only const payment_details* find_matching_payment(
// from wallet2's callback thread. const std::list<std::pair<crypto::hash, payment_details>> pds,
void Wallet::handleBalanceChanged(uint64_t at_block_height) { uint64_t amount,
LOGV("handleBalanceChanged(%lu)", at_block_height); const crypto::hash& txid,
m_tx_outs_mutex.lock(); const cryptonote::subaddress_index& subaddr_index) {
m_wallet.get_transfers(m_tx_outs); if (txid == crypto::null_hash) {
m_tx_outs_mutex.unlock(); return nullptr;
m_blockchain_height = at_block_height; }
callOnRefresh(true); 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;
}
}
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<TxInfo>& snapshot) {
snapshot.clear();
std::vector<transfer_details> tds;
m_wallet.get_transfers(tds);
uint64_t min_height = 0;
std::list<std::pair<crypto::hash, payment_details>> pds;
std::list<std::pair<crypto::hash, pool_payment_details>> upds;
std::list<std::pair<crypto::hash, confirmed_transfer_details>> txs;
std::list<std::pair<crypto::hash, unconfirmed_transfer_details>> 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;
}
} }
void Wallet::handleNewBlock(uint64_t height) { // 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<cryptonote::txin_to_key>(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<cryptonote::txin_to_key>(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::handleNewBlock(uint64_t height, bool refresh_running) {
m_blockchain_height = height; m_blockchain_height = height;
// Notify the blockchain height once every 200 ms if the height is a multiple of 100. if (m_balance_changed) {
bool debounce = true; m_tx_history_mutex.lock();
if (height % 100 == 0) { 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; 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(); auto now = std::chrono::steady_clock::now();
if (now - last_time >= 200.ms) { if (now - last_time >= 200.ms) {
last_time = now; last_time = now;
debounce = false; debounce = false;
} }
} }
} else {
last_time = std::chrono::steady_clock::now();
}
if (!debounce) { if (!debounce) {
callOnRefresh(false); m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh,
m_blockchain_height, m_balance_changed);
} }
} }
void Wallet::callOnRefresh(bool balance_changed) {
m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh, m_blockchain_height, balance_changed);
}
Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) { Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
LOG_FATAL_IF(m_refresh_running.exchange(true), LOG_FATAL_IF(m_refresh_running.exchange(true),
"Refresh should not be called concurrently"); "Refresh should not be called concurrently");
@ -170,7 +367,7 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
m_wallet.set_refresh_from_block_height(m_restore_height); m_wallet.set_refresh_from_block_height(m_restore_height);
try { try {
// refresh() will block until stop() is called or it syncs successfully. // 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()) { if (!m_wallet.stopped()) {
m_wallet.stop(); m_wallet.stop();
ret = Status::OK; ret = Status::OK;
@ -190,9 +387,8 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
ret = Status::INTERRUPTED; ret = Status::INTERRUPTED;
} }
m_refresh_running.store(false); m_refresh_running.store(false);
m_blockchain_height = m_wallet.get_blockchain_current_height(); // Ensure the latest block and pool state are consistently processed.
// Always notify the last block height. handleNewBlock(m_wallet.get_blockchain_current_height(), false);
callOnRefresh(false);
return ret; return ret;
} }
@ -214,6 +410,7 @@ auto Wallet::suspendRefreshAndRunLocked(T block) -> decltype(block()) {
m_callback.callVoidMethod(env, WalletNative_onSuspendRefresh, false); m_callback.callVoidMethod(env, WalletNative_onSuspendRefresh, false);
m_refresh_cond.notify_one(); m_refresh_cond.notify_one();
} }
// Call the lambda and release the mutex upon completion.
return block(); return block();
} }
@ -347,7 +544,7 @@ Java_im_molly_monero_WalletNative_nativeSetRefreshSince(
extern "C" extern "C"
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress( Java_im_molly_monero_WalletNative_nativeGetAccountPrimaryAddress(
JNIEnv* env, JNIEnv* env,
jobject thiz, jobject thiz,
jlong handle) { jlong handle) {
@ -356,47 +553,54 @@ Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress(
} }
extern "C" extern "C"
JNIEXPORT jlong JNICALL JNIEXPORT jint JNICALL
Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight( Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight(
JNIEnv* env, JNIEnv* env,
jobject thiz, jobject thiz,
jlong handle) { jlong handle) {
auto* wallet = reinterpret_cast<Wallet*>(handle); auto* wallet = reinterpret_cast<Wallet*>(handle);
uint64_t height = wallet->current_blockchain_height(); uint64_t height = wallet->current_blockchain_height();
LOG_FATAL_IF(height > std::numeric_limits<jlong>::max(), LOG_FATAL_IF(height >= CRYPTONOTE_MAX_BLOCK_NUMBER,
"Blockchain height overflowed jlong"); "Blockchain max height reached");
return static_cast<jlong>(height); return static_cast<jint>(height);
} }
ScopedJvmLocalRef<jobject> nativeToJvmOwnedTxOut(JNIEnv* env, ScopedJvmLocalRef<jobject> nativeToJvmTxInfo(JNIEnv* env,
const TxOut& tx_out) { const TxInfo& info) {
LOG_FATAL_IF(tx_out.m_spent LOG_FATAL_IF(info.m_height >= CRYPTONOTE_MAX_BLOCK_NUMBER,
&& (tx_out.m_spent_height == 0 || "Blockchain max height reached");
tx_out.m_spent_height < tx_out.m_block_height), return {env, TxInfoClass.newObject(
"Unexpected spent block height in tx output"); env, TxInfo_ctor,
return {env, OwnedTxOut.newObject( nativeToJvmString(env, pod_to_hex(info.m_tx_hash)).obj(),
env, nativeToJvmString(env, pod_to_hex(info.m_key)).obj(),
OwnedTxOut_ctor, info.m_key_image_known ? nativeToJvmString(env, pod_to_hex(info.m_key_image)).obj(): nullptr,
nativeToJvmByteArray(env, tx_out.m_txid.data, sizeof(tx_out.m_txid.data)).obj(), info.m_subaddress_major,
tx_out.m_amount, info.m_subaddress_minor,
tx_out.m_block_height, (!info.m_recipient.empty()) ? nativeToJvmString(env, info.m_recipient).obj() : nullptr,
tx_out.m_spent_height) info.m_amount,
static_cast<jint>(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" extern "C"
JNIEXPORT jobjectArray JNICALL JNIEXPORT jobjectArray JNICALL
Java_im_molly_monero_WalletNative_nativeGetOwnedTxOuts( Java_im_molly_monero_WalletNative_nativeGetTxHistory(
JNIEnv* env, JNIEnv* env,
jobject thiz, jobject thiz,
jlong handle) { jlong handle) {
auto* wallet = reinterpret_cast<Wallet*>(handle); auto* wallet = reinterpret_cast<Wallet*>(handle);
ScopedJvmLocalRef<jobjectArray> j_array; ScopedJvmLocalRef<jobjectArray> j_array;
wallet->getOwnedTxOuts([env, &j_array](std::vector<TxOut> const& tx_outs) { wallet->withTxHistory([env, &j_array](std::vector<TxInfo> const& txs) {
j_array = nativeToJvmObjectArray(env, j_array = nativeToJvmObjectArray(env,
tx_outs, txs,
OwnedTxOut.getClass(), TxInfoClass.getClass(),
&nativeToJvmOwnedTxOut); &nativeToJvmTxInfo);
}); });
return j_array.Release(); return j_array.Release();
} }

View File

@ -1,7 +1,7 @@
#ifndef WALLET_H_ #ifndef WALLET_H_
#define WALLET_H_ #define WALLET_H_
#include <iostream> #include <ostream>
#include "http_client.h" #include "http_client.h"
#include "jvm.h" #include "jvm.h"
@ -10,7 +10,61 @@
namespace monero { 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. // Wrapper for wallet2.h core API.
class Wallet : tools::i_wallet2_callback { class Wallet : tools::i_wallet2_callback {
@ -36,14 +90,14 @@ class Wallet : tools::i_wallet2_callback {
void cancelRefresh(); void cancelRefresh();
void setRefreshSince(long height_or_timestamp); void setRefreshSince(long height_or_timestamp);
template<typename Callback> template<typename Consumer>
void getOwnedTxOuts(Callback callback); void withTxHistory(Consumer consumer);
std::string public_address() const; std::string public_address() const;
uint64_t current_blockchain_height() const { return m_blockchain_height; } 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() BEGIN_SERIALIZE_OBJECT()
VERSION_FIELD(0) VERSION_FIELD(0)
FIELD(m_restore_height) FIELD(m_restore_height)
@ -54,14 +108,16 @@ class Wallet : tools::i_wallet2_callback {
tools::wallet2 m_wallet; tools::wallet2 m_wallet;
std::vector<TxOut> m_tx_outs;
bool m_account_ready; bool m_account_ready;
uint64_t m_restore_height; uint64_t m_restore_height;
uint64_t m_blockchain_height; uint64_t m_blockchain_height;
// Saved transaction history.
std::vector<TxInfo> m_tx_history;
// Protects access to m_wallet instance and state fields. // Protects access to m_wallet instance and state fields.
std::mutex m_wallet_mutex; std::mutex m_wallet_mutex;
std::mutex m_tx_outs_mutex; std::mutex m_tx_history_mutex;
std::mutex m_refresh_mutex; std::mutex m_refresh_mutex;
// Reference to Kotlin wallet instance. // Reference to Kotlin wallet instance.
@ -70,25 +126,26 @@ class Wallet : tools::i_wallet2_callback {
std::condition_variable m_refresh_cond; std::condition_variable m_refresh_cond;
std::atomic<bool> m_refresh_running; std::atomic<bool> m_refresh_running;
bool m_refresh_canceled; bool m_refresh_canceled;
bool m_balance_changed;
void notifyRefresh(bool debounce);
template<typename T> template<typename T>
auto suspendRefreshAndRunLocked(T block) -> decltype(block()); auto suspendRefreshAndRunLocked(T block) -> decltype(block());
void handleBalanceChanged(uint64_t at_block_height); void captureTxHistorySnapshot(std::vector<TxInfo>& snapshot);
void handleNewBlock(uint64_t height); void handleNewBlock(uint64_t height, bool refresh_running);
void handleReorgEvent(uint64_t at_block_height);
void callOnRefresh(bool balance_changed); void handleMoneyEvent(uint64_t at_block_height);
// Implementation of i_wallet2_callback follows. // Implementation of i_wallet2_callback follows.
private: private:
void on_new_block(uint64_t height, const cryptonote::block& block) override { 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 { void on_reorg(uint64_t height) override {
if (transfers_detached > 0) { handleReorgEvent(height);
handleBalanceChanged(height);
}
} }
void on_money_received(uint64_t height, void on_money_received(uint64_t height,
@ -99,7 +156,7 @@ class Wallet : tools::i_wallet2_callback {
const cryptonote::subaddress_index& subaddr_index, const cryptonote::subaddress_index& subaddr_index,
bool is_change, bool is_change,
uint64_t unlock_time) override { uint64_t unlock_time) override {
handleBalanceChanged(height); handleMoneyEvent(height);
} }
void on_unconfirmed_money_received(uint64_t height, void on_unconfirmed_money_received(uint64_t height,
@ -107,8 +164,8 @@ class Wallet : tools::i_wallet2_callback {
const cryptonote::transaction& tx, const cryptonote::transaction& tx,
uint64_t amount, uint64_t amount,
const cryptonote::subaddress_index& subaddr_index) override { const cryptonote::subaddress_index& subaddr_index) override {
handleBalanceChanged(height); handleMoneyEvent(height);
}; }
void on_money_spent(uint64_t height, void on_money_spent(uint64_t height,
const crypto::hash& txid, const crypto::hash& txid,
@ -116,7 +173,7 @@ class Wallet : tools::i_wallet2_callback {
uint64_t amount, uint64_t amount,
const cryptonote::transaction& spend_tx, const cryptonote::transaction& spend_tx,
const cryptonote::subaddress_index& subaddr_index) override { const cryptonote::subaddress_index& subaddr_index) override {
handleBalanceChanged(height); handleMoneyEvent(height);
}; };
}; };

View File

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

View File

@ -3,12 +3,20 @@ package im.molly.monero
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
// TODO: Rename to MoneroAmount?
@JvmInline @JvmInline
@Parcelize @Parcelize
value class AtomicAmount(val value: Long) : Parcelable { value class AtomicAmount(val value: Long) : Parcelable {
operator fun plus(other: AtomicAmount) = AtomicAmount(Math.addExact(this.value, other.value)) 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) operator fun compareTo(other: Int): Int = value.compareTo(other)
companion object {
val ZERO = AtomicAmount(0)
}
} }
fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this) fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this)
@ -16,9 +24,17 @@ fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this)
fun Int.toAtomicAmount(): AtomicAmount = AtomicAmount(this.toLong()) fun Int.toAtomicAmount(): AtomicAmount = AtomicAmount(this.toLong())
inline fun <T> Iterable<T>.sumOf(selector: (T) -> AtomicAmount): AtomicAmount { inline fun <T> Iterable<T>.sumOf(selector: (T) -> AtomicAmount): AtomicAmount {
var sum: AtomicAmount = 0L.toAtomicAmount() var sum: AtomicAmount = AtomicAmount.ZERO
for (element in this) { for (element in this) {
sum += selector(element) sum += selector(element)
} }
return sum return sum
} }
fun Iterable<AtomicAmount>.sum(): AtomicAmount {
var sum: AtomicAmount = AtomicAmount.ZERO
for (element in this) {
sum += element
}
return sum
}

View File

@ -1,23 +1,31 @@
package im.molly.monero package im.molly.monero
data class Balance( data class Balance(
private val spendableTxOuts: Set<OwnedTxOut>, val pendingBalance: AtomicAmount,
val timeLockedAmounts: Set<TimeLocked<AtomicAmount>>,
) { ) {
val totalAmount: AtomicAmount = spendableTxOuts.sumOf { it.amount } val confirmedBalance: AtomicAmount = timeLockedAmounts.sumOf { it.value }
fun totalAmountUnlockedAt( fun unlockedBalance(currentTime: BlockchainTime): AtomicAmount =
blockHeight: Long, timeLockedAmounts
timestampMillis: Long = System.currentTimeMillis() .mapNotNull { it.getValueIfUnlocked(currentTime) }
// TODO: Create Timelock class .sum()
): AtomicAmount {
require(blockHeight > 0) fun lockedBalance(currentTime: BlockchainTime): Map<BlockchainTimeSpan, AtomicAmount> =
require(timestampMillis >= 0) timeLockedAmounts
TODO() .filter { it.isLocked(currentTime) }
.groupBy({ it.timeUntilUnlock(currentTime) }, { it.value })
.mapValues { (_, amounts) -> amounts.sum() }
} }
companion object { fun Iterable<TimeLocked<Enote>>.balance(subAccountSelector: (Int) -> Boolean = { true }): Balance {
fun of(txOuts: List<OwnedTxOut>) = Balance( val enotes = filter { subAccountSelector(it.value.owner.accountIndex) }
spendableTxOuts = txOuts.filter { it.notSpent }.toSet(), 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)
} }

View File

@ -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<String>,
) {
// 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())

View File

@ -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<BlockchainTime> {
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<T>(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
}
}
}

View File

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

View File

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

View File

@ -2,12 +2,13 @@ package im.molly.monero
//import im.molly.monero.proto.LedgerProto //import im.molly.monero.proto.LedgerProto
data class Ledger constructor( data class Ledger(
val publicAddress: String, val publicAddress: String,
val receivedOutputs: List<OwnedTxOut>, val txs: Map<String, Transaction>,
val checkedAtBlockHeight: Long, val spendableEnotes: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime,
) { ) {
val balance = Balance.of(receivedOutputs) val balance = spendableEnotes.balance()
// companion object { // companion object {
// fun fromProto(proto: LedgerProto) = Ledger( // fun fromProto(proto: LedgerProto) = Ledger(

View File

@ -1,5 +1,6 @@
package im.molly.monero package im.molly.monero
import im.molly.monero.internal.constants.CRYPTONOTE_DISPLAY_DECIMAL_POINT
import java.math.BigDecimal import java.math.BigDecimal
import java.text.NumberFormat import java.text.NumberFormat
import java.util.* import java.util.*
@ -8,7 +9,7 @@ object MoneroCurrency {
const val symbol = "XMR" const val symbol = "XMR"
fun format(atomicAmount: AtomicAmount, formatter: NumberFormat = DefaultFormatter): String = 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 { fun parse(source: String, formatter: NumberFormat = DefaultFormatter): AtomicAmount {
TODO() TODO()

View File

@ -1,11 +1,14 @@
package im.molly.monero package im.molly.monero
import im.molly.monero.internal.TxInfo
import im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import java.time.Instant
class MoneroWallet internal constructor( class MoneroWallet internal constructor(
private val wallet: IWallet, private val wallet: IWallet,
@ -15,7 +18,7 @@ class MoneroWallet internal constructor(
private val logger = loggerFor<MoneroWallet>() private val logger = loggerFor<MoneroWallet>()
val primaryAddress: String = wallet.primaryAccountAddress val primaryAddress: String = wallet.accountPrimaryAddress
var dataStore by storageAdapter::dataStore var dataStore by storageAdapter::dataStore
@ -26,13 +29,17 @@ class MoneroWallet internal constructor(
val listener = object : IBalanceListener.Stub() { val listener = object : IBalanceListener.Stub() {
lateinit var lastKnownLedger: Ledger lateinit var lastKnownLedger: Ledger
override fun onBalanceChanged(txOuts: List<OwnedTxOut>?, checkedAtBlockHeight: Long) { override fun onBalanceChanged(txHistory: List<TxInfo>, blockchainHeight: Int) {
lastKnownLedger = Ledger(primaryAddress, txOuts!!, checkedAtBlockHeight) val now = Instant.now()
val checkedAt = BlockchainTime(blockchainHeight, now)
val (txs, spendableEnotes) = txHistory.consolidateTransactions(checkedAt)
lastKnownLedger = Ledger(primaryAddress, txs, spendableEnotes, checkedAt)
sendLedger(lastKnownLedger) sendLedger(lastKnownLedger)
} }
override fun onRefresh(blockchainHeight: Long) { override fun onRefresh(blockHeight: Int) {
sendLedger(lastKnownLedger.copy(checkedAtBlockHeight = blockchainHeight)) val checkedAt = BlockchainTime.Block(blockHeight)
sendLedger(lastKnownLedger.copy(checkedAt = checkedAt))
} }
private fun sendLedger(ledger: Ledger) { private fun sendLedger(ledger: Ledger) {
@ -52,7 +59,7 @@ class MoneroWallet internal constructor(
skipCoinbaseOutputs: Boolean = false, skipCoinbaseOutputs: Boolean = false,
): RefreshResult = suspendCancellableCoroutine { continuation -> ): RefreshResult = suspendCancellableCoroutine { continuation ->
wallet.resumeRefresh(skipCoinbaseOutputs, object : BaseWalletCallbacks() { wallet.resumeRefresh(skipCoinbaseOutputs, object : BaseWalletCallbacks() {
override fun onRefreshResult(blockHeight: Long, status: Int) { override fun onRefreshResult(blockHeight: Int, status: Int) {
val result = RefreshResult(blockHeight, status) val result = RefreshResult(blockHeight, status)
continuation.resume(result) {} continuation.resume(result) {}
} }
@ -74,11 +81,11 @@ class MoneroWallet internal constructor(
} }
private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() { 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 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 fun isError() = status != WalletNative.Status.OK
} }

View File

@ -0,0 +1,3 @@
package im.molly.monero
enum class ProtocolInfo

View File

@ -1,3 +1,8 @@
package im.molly.monero package im.molly.monero
class PublicKey @JvmInline
value class PublicKey(private val publicKey: String) {
init {
require(publicKey.length == 64) { "Public key length must be 64 bytes" }
}
}

View File

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

View File

@ -12,6 +12,8 @@ import javax.security.auth.Destroyable
* *
* SecretKey wraps a secret scalar value, helping to prevent accidental exposure and securely * SecretKey wraps a secret scalar value, helping to prevent accidental exposure and securely
* erasing the value from memory. * erasing the value from memory.
*
* This class is not thread-safe.
*/ */
class SecretKey : Destroyable, Closeable, Parcelable { class SecretKey : Destroyable, Closeable, Parcelable {
@ -22,7 +24,7 @@ class SecretKey : Destroyable, Closeable, Parcelable {
} }
constructor(secretScalar: ByteArray) { 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) secretScalar.copyInto(secret)
} }
@ -30,6 +32,9 @@ class SecretKey : Destroyable, Closeable, Parcelable {
parcel.readByteArray(secret) parcel.readByteArray(secret)
} }
internal val isNonZero
get() = !MessageDigest.isEqual(secret, ByteArray(secret.size))
val bytes: ByteArray val bytes: ByteArray
get() { get() {
check(!destroyed) { "Secret key has been already destroyed" } check(!destroyed) { "Secret key has been already destroyed" }
@ -37,17 +42,14 @@ class SecretKey : Destroyable, Closeable, Parcelable {
return secret.clone() return secret.clone()
} }
val isNonZero
get() = !MessageDigest.isEqual(secret, ByteArray(secret.size))
var destroyed = false var destroyed = false
private set private set
override fun destroy() { override fun destroy() {
if (!destroyed) { if (!destroyed) {
secret.fill(0) secret.fill(0)
destroyed = true
} }
destroyed = true
} }
override fun writeToParcel(parcel: Parcel, flags: Int) { override fun writeToParcel(parcel: Parcel, flags: Int) {
@ -68,16 +70,10 @@ class SecretKey : Destroyable, Closeable, Parcelable {
} }
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean =
if (this === other) return true this === other || other is SecretKey && MessageDigest.isEqual(secret, other.secret)
if (other !is SecretKey) return false
return MessageDigest.isEqual(secret, other.secret) override fun hashCode(): Int = secret.contentHashCode()
}
override fun hashCode(): Int {
return secret.contentHashCode()
}
override fun close() = destroy() override fun close() = destroy()

View File

@ -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<Enote>,
val received: Set<Enote>,
val payments: List<PaymentDetail>,
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,
)

View File

@ -2,6 +2,8 @@ package im.molly.monero
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.annotation.GuardedBy import androidx.annotation.GuardedBy
import im.molly.monero.internal.TxInfo
import im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.Closeable import java.io.Closeable
import java.util.* import java.util.*
@ -93,21 +95,21 @@ class WalletNative private constructor(
} }
readFd.use { readFd.use {
if (!nativeLoad(handle, it.fd)) { 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) get() = nativeGetCurrentBlockchainHeight(handle)
val currentBalance: Balance val currentBalance: Balance
get() = Balance.of(ownedTxOutsSnapshot()) get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance()
private fun ownedTxOutsSnapshot(): List<OwnedTxOut> = nativeGetOwnedTxOuts(handle).toList() private fun txHistorySnapshot(): List<TxInfo> = nativeGetTxHistory(handle).toList()
@GuardedBy("listenersLock") @GuardedBy("listenersLock")
private val balanceListeners = mutableSetOf<IBalanceListener>() private val balanceListeners = mutableSetOf<IBalanceListener>()
@ -161,7 +163,7 @@ class WalletNative private constructor(
requireNotNull(listener) requireNotNull(listener)
balanceListenersLock.withLock { balanceListenersLock.withLock {
balanceListeners.add(listener) balanceListeners.add(listener)
listener.onBalanceChanged(ownedTxOutsSnapshot(), currentBlockchainHeight) listener.onBalanceChanged(txHistorySnapshot(), currentBlockchainHeight)
} }
} }
@ -173,13 +175,12 @@ class WalletNative private constructor(
} }
@CalledByNative("wallet.cc") @CalledByNative("wallet.cc")
private fun onRefresh(blockchainHeight: Long, balanceChanged: Boolean) { private fun onRefresh(blockchainHeight: Int, balanceChanged: Boolean) {
balanceListenersLock.withLock { balanceListenersLock.withLock {
if (balanceListeners.isNotEmpty()) { if (balanceListeners.isNotEmpty()) {
val call = fun(listener: IBalanceListener) { val call = fun(listener: IBalanceListener) {
if (balanceChanged) { if (balanceChanged) {
val txOuts = ownedTxOutsSnapshot() listener.onBalanceChanged(txHistorySnapshot(), blockchainHeight)
listener.onBalanceChanged(txOuts, blockchainHeight)
} else { } else {
listener.onRefresh(blockchainHeight) listener.onRefresh(blockchainHeight)
} }
@ -262,9 +263,10 @@ class WalletNative private constructor(
private external fun nativeCancelRefresh(handle: Long) private external fun nativeCancelRefresh(handle: Long)
private external fun nativeCreate(networkId: Int): Long private external fun nativeCreate(networkId: Int): Long
private external fun nativeDispose(handle: Long) private external fun nativeDispose(handle: Long)
private external fun nativeGetCurrentBlockchainHeight(handle: Long): Long private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int
private external fun nativeGetOwnedTxOuts(handle: Long): Array<OwnedTxOut> private external fun nativeGetTxHistory(handle: Long): Array<TxInfo>
private external fun nativeGetPrimaryAccountAddress(handle: Long): String 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 nativeLoad(handle: Long, fd: Int): Boolean
private external fun nativeNonReentrantRefresh(handle: Long, skipCoinbase: Boolean): Int private external fun nativeNonReentrantRefresh(handle: Long, skipCoinbase: Boolean): Int
private external fun nativeRestoreAccount( private external fun nativeRestoreAccount(

View File

@ -69,7 +69,7 @@ class WalletProvider private constructor(
dataStore: WalletDataStore? = null, dataStore: WalletDataStore? = null,
client: RemoteNodeClient? = null, client: RemoteNodeClient? = null,
secretSpendKey: SecretKey, secretSpendKey: SecretKey,
restorePoint: RestorePoint, restorePoint: BlockchainTime,
): MoneroWallet { ): MoneroWallet {
require(client == null || client.network == network) require(client == null || client.network == network)
val storageAdapter = StorageAdapter(dataStore) val storageAdapter = StorageAdapter(dataStore)
@ -78,7 +78,7 @@ class WalletProvider private constructor(
buildConfig(network), storageAdapter, client, buildConfig(network), storageAdapter, client,
WalletResultCallback(continuation), WalletResultCallback(continuation),
secretSpendKey, secretSpendKey,
restorePoint.heightOrTimestamp, restorePoint.toLong(),
) )
} }
return MoneroWallet(wallet, storageAdapter, client) return MoneroWallet(wallet, storageAdapter, client)

View File

@ -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<TxInfo>.consolidateTransactions(
blockchainTime: BlockchainTime,
): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> {
val enoteMap = mutableMapOf<String, Enote>()
val keyImageMap = mutableMapOf<String, Enote>()
val spentSet = mutableSetOf<String>()
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<TxInfo>,
enoteMap: Map<String, Enote>,
keyImageMap: Map<String, Enote>,
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<TxInfo>): 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!!),
)

View File

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

View File

@ -61,7 +61,8 @@ object Decoder {
out.position(newOutPos) out.position(newOutPos)
} }
private fun findOutputBlockSize(blockSize: Int): Int = blockSizes[blockSize].also { private fun findOutputBlockSize(blockSize: Int): Int =
blockSizes[blockSize].also {
require(it >= 0) { "Invalid block size" } require(it >= 0) { "Invalid block size" }
} }
} }

View File

@ -7,3 +7,5 @@ fun CharSequence.parseHex(): ByteArray {
Integer.parseInt(substring(it * 2, (it + 1) * 2), 16).toByte() Integer.parseInt(substring(it * 2, (it + 1) * 2), 16).toByte()
} }
} }
fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) }

View File

@ -43,11 +43,10 @@ class SecretKeyTest {
@Test @Test
fun `two keys with same secret are the same`() { fun `two keys with same secret are the same`() {
val secret = Random.nextBytes(32) val secret = Random.nextBytes(32)
val anotherSecret = Random.nextBytes(32)
val key = SecretKey(secret) val key = SecretKey(secret)
val sameKey = SecretKey(secret) val sameKey = SecretKey(secret)
val anotherKey = SecretKey(anotherSecret) val anotherKey = randomSecretKey()
assertThat(key).isEqualTo(sameKey) assertThat(key).isEqualTo(sameKey)
assertThat(sameKey).isNotEqualTo(anotherKey) assertThat(sameKey).isNotEqualTo(anotherKey)
@ -61,4 +60,22 @@ class SecretKeyTest {
assertThat(randomKeys).hasSize(times) 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)
}
} }