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,
filename: String,
secretSpendKey: SecretKey,
restorePoint: RestorePoint,
restorePoint: BlockchainTime,
): MoneroWallet {
val provider = providerDeferred.await()
return provider.restoreWallet(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
#ifndef WALLET_H_
#define WALLET_H_
#include <iostream>
#include <ostream>
#include "http_client.h"
#include "jvm.h"
@ -10,7 +10,61 @@
namespace monero {
using TxOut = tools::wallet2::transfer_details;
using transfer_details = tools::wallet2::transfer_details;
using payment_details = tools::wallet2::payment_details;
using pool_payment_details = tools::wallet2::pool_payment_details;
using confirmed_transfer_details = tools::wallet2::confirmed_transfer_details;
using unconfirmed_transfer_details = tools::wallet2::unconfirmed_transfer_details;
// Basic structure combining transaction details with input or output info.
struct TxInfo {
crypto::hash m_tx_hash;
crypto::public_key m_key;
crypto::key_image m_key_image;
uint32_t m_subaddress_major;
uint32_t m_subaddress_minor;
std::string m_recipient;
uint64_t m_amount;
uint64_t m_height;
uint64_t m_unlock_time;
uint64_t m_timestamp;
uint64_t m_fee;
uint64_t m_change;
bool m_coinbase;
bool m_key_image_known;
enum TxType {
INCOMING = 0,
OUTGOING = 1,
} m_type;
enum TxState {
OFF_CHAIN = 0,
PENDING = 1,
FAILED = 2,
ON_CHAIN = 3,
} m_state;
TxInfo(crypto::hash tx_hash, TxType type):
m_tx_hash(tx_hash),
m_key(crypto::public_key{}),
m_key_image(crypto::key_image{}),
m_subaddress_major(-1),
m_subaddress_minor(-1),
m_recipient(),
m_amount(0),
m_height(0),
m_unlock_time(0),
m_timestamp(0),
m_fee(0),
m_change(0),
m_coinbase(false),
m_key_image_known(false),
m_type(type),
m_state(OFF_CHAIN) {}
// TODO: Factory functions for various types of transactions.
};
// Wrapper for wallet2.h core API.
class Wallet : tools::i_wallet2_callback {
@ -36,14 +90,14 @@ class Wallet : tools::i_wallet2_callback {
void cancelRefresh();
void setRefreshSince(long height_or_timestamp);
template<typename Callback>
void getOwnedTxOuts(Callback callback);
template<typename Consumer>
void withTxHistory(Consumer consumer);
std::string public_address() const;
uint64_t current_blockchain_height() const { return m_blockchain_height; }
// Extra state that must be persistent and isn't restored by wallet2's serializer.
// Extra state that must be persistent but isn't restored by wallet2's serializer.
BEGIN_SERIALIZE_OBJECT()
VERSION_FIELD(0)
FIELD(m_restore_height)
@ -54,14 +108,16 @@ class Wallet : tools::i_wallet2_callback {
tools::wallet2 m_wallet;
std::vector<TxOut> m_tx_outs;
bool m_account_ready;
uint64_t m_restore_height;
uint64_t m_blockchain_height;
// Saved transaction history.
std::vector<TxInfo> m_tx_history;
// Protects access to m_wallet instance and state fields.
std::mutex m_wallet_mutex;
std::mutex m_tx_outs_mutex;
std::mutex m_tx_history_mutex;
std::mutex m_refresh_mutex;
// Reference to Kotlin wallet instance.
@ -70,25 +126,26 @@ class Wallet : tools::i_wallet2_callback {
std::condition_variable m_refresh_cond;
std::atomic<bool> m_refresh_running;
bool m_refresh_canceled;
bool m_balance_changed;
void notifyRefresh(bool debounce);
template<typename T>
auto suspendRefreshAndRunLocked(T block) -> decltype(block());
void handleBalanceChanged(uint64_t at_block_height);
void handleNewBlock(uint64_t height);
void callOnRefresh(bool balance_changed);
void captureTxHistorySnapshot(std::vector<TxInfo>& snapshot);
void handleNewBlock(uint64_t height, bool refresh_running);
void handleReorgEvent(uint64_t at_block_height);
void handleMoneyEvent(uint64_t at_block_height);
// Implementation of i_wallet2_callback follows.
private:
void on_new_block(uint64_t height, const cryptonote::block& block) override {
handleNewBlock(height);
handleNewBlock(height, true);
}
void on_reorg(uint64_t height, size_t blocks_detached, size_t transfers_detached) override {
if (transfers_detached > 0) {
handleBalanceChanged(height);
}
void on_reorg(uint64_t height) override {
handleReorgEvent(height);
}
void on_money_received(uint64_t height,
@ -99,7 +156,7 @@ class Wallet : tools::i_wallet2_callback {
const cryptonote::subaddress_index& subaddr_index,
bool is_change,
uint64_t unlock_time) override {
handleBalanceChanged(height);
handleMoneyEvent(height);
}
void on_unconfirmed_money_received(uint64_t height,
@ -107,8 +164,8 @@ class Wallet : tools::i_wallet2_callback {
const cryptonote::transaction& tx,
uint64_t amount,
const cryptonote::subaddress_index& subaddr_index) override {
handleBalanceChanged(height);
};
handleMoneyEvent(height);
}
void on_money_spent(uint64_t height,
const crypto::hash& txid,
@ -116,7 +173,7 @@ class Wallet : tools::i_wallet2_callback {
uint64_t amount,
const cryptonote::transaction& spend_tx,
const cryptonote::subaddress_index& subaddr_index) override {
handleBalanceChanged(height);
handleMoneyEvent(height);
};
};

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 kotlinx.parcelize.Parcelize
// TODO: Rename to MoneroAmount?
@JvmInline
@Parcelize
value class AtomicAmount(val value: Long) : Parcelable {
operator fun plus(other: AtomicAmount) = AtomicAmount(Math.addExact(this.value, other.value))
operator fun minus(other: AtomicAmount) = AtomicAmount(Math.subtractExact(this.value, other.value))
operator fun compareTo(other: Int): Int = value.compareTo(other)
companion object {
val ZERO = AtomicAmount(0)
}
}
fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this)
@ -16,9 +24,17 @@ fun Long.toAtomicAmount(): AtomicAmount = AtomicAmount(this)
fun Int.toAtomicAmount(): AtomicAmount = AtomicAmount(this.toLong())
inline fun <T> Iterable<T>.sumOf(selector: (T) -> AtomicAmount): AtomicAmount {
var sum: AtomicAmount = 0L.toAtomicAmount()
var sum: AtomicAmount = AtomicAmount.ZERO
for (element in this) {
sum += selector(element)
}
return sum
}
fun Iterable<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
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(
blockHeight: Long,
timestampMillis: Long = System.currentTimeMillis()
// TODO: Create Timelock class
): AtomicAmount {
require(blockHeight > 0)
require(timestampMillis >= 0)
TODO()
}
fun unlockedBalance(currentTime: BlockchainTime): AtomicAmount =
timeLockedAmounts
.mapNotNull { it.getValueIfUnlocked(currentTime) }
.sum()
companion object {
fun of(txOuts: List<OwnedTxOut>) = Balance(
spendableTxOuts = txOuts.filter { it.notSpent }.toSet(),
)
}
fun lockedBalance(currentTime: BlockchainTime): Map<BlockchainTimeSpan, AtomicAmount> =
timeLockedAmounts
.filter { it.isLocked(currentTime) }
.groupBy({ it.timeUntilUnlock(currentTime) }, { it.value })
.mapValues { (_, amounts) -> amounts.sum() }
}
fun Iterable<TimeLocked<Enote>>.balance(subAccountSelector: (Int) -> Boolean = { true }): Balance {
val enotes = filter { subAccountSelector(it.value.owner.accountIndex) }
val (pending, confirmed) = enotes.partition { it.value.age == 0 }
val timeLockedSet = confirmed
.groupBy({ it.unlockTime }, { it.value.amount })
.map { (unlockTime, amounts) -> TimeLocked(amounts.sum(), unlockTime) }
.toSet()
return Balance(pending.sumOf { it.value.amount }, timeLockedSet)
}

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
data class Ledger constructor(
data class Ledger(
val publicAddress: String,
val receivedOutputs: List<OwnedTxOut>,
val checkedAtBlockHeight: Long,
val txs: Map<String, Transaction>,
val spendableEnotes: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime,
) {
val balance = Balance.of(receivedOutputs)
val balance = spendableEnotes.balance()
// companion object {
// fun fromProto(proto: LedgerProto) = Ledger(

View File

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

View File

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

View File

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

View File

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

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

View File

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

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)
}
private fun findOutputBlockSize(blockSize: Int): Int = blockSizes[blockSize].also {
private fun findOutputBlockSize(blockSize: Int): Int =
blockSizes[blockSize].also {
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()
}
}
fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) }

View File

@ -43,11 +43,10 @@ class SecretKeyTest {
@Test
fun `two keys with same secret are the same`() {
val secret = Random.nextBytes(32)
val anotherSecret = Random.nextBytes(32)
val key = SecretKey(secret)
val sameKey = SecretKey(secret)
val anotherKey = SecretKey(anotherSecret)
val anotherKey = randomSecretKey()
assertThat(key).isEqualTo(sameKey)
assertThat(sameKey).isNotEqualTo(anotherKey)
@ -61,4 +60,22 @@ class SecretKeyTest {
assertThat(randomKeys).hasSize(times)
}
@Test
fun `keys are not equal to their destroyed versions`() {
val secret = Random.nextBytes(32)
val key = SecretKey(secret)
val destroyed = SecretKey(secret).also { it.destroy() }
assertThat(key).isNotEqualTo(destroyed)
}
@Test
fun `destroyed keys are equal`() {
val destroyed = randomSecretKey().also { it.destroy() }
val anotherDestroyed = randomSecretKey().also { it.destroy() }
assertThat(destroyed).isEqualTo(anotherDestroyed)
}
}