mirror of
https://github.com/mollyim/monero-wallet-sdk.git
synced 2025-01-11 23:49:31 -05:00
lib: transaction history processing
This commit is contained in:
parent
acfb1aca5e
commit
48fb61d1b3
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
package im.molly.monero;
|
||||
|
||||
parcelable OwnedTxOut;
|
@ -0,0 +1,3 @@
|
||||
package im.molly.monero.internal;
|
||||
|
||||
parcelable TxInfo;
|
@ -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");
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
void Wallet::handleNewBlock(uint64_t height) {
|
||||
m_blockchain_height = height;
|
||||
// Notify the blockchain height once every 200 ms if the height is a multiple of 100.
|
||||
bool debounce = true;
|
||||
if (height % 100 == 0) {
|
||||
static std::chrono::steady_clock::time_point last_time;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now - last_time >= 200.ms) {
|
||||
last_time = now;
|
||||
debounce = false;
|
||||
const payment_details* find_matching_payment(
|
||||
const std::list<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;
|
||||
}
|
||||
}
|
||||
if (!debounce) {
|
||||
callOnRefresh(false);
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
// Only call this function from the callback thread or during initialization,
|
||||
// as there is no locking mechanism to safeguard reading transaction history
|
||||
// from wallet2.
|
||||
void Wallet::captureTxHistorySnapshot(std::vector<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::callOnRefresh(bool balance_changed) {
|
||||
m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh, m_blockchain_height, balance_changed);
|
||||
void Wallet::handleNewBlock(uint64_t height, bool refresh_running) {
|
||||
m_blockchain_height = height;
|
||||
if (m_balance_changed) {
|
||||
m_tx_history_mutex.lock();
|
||||
captureTxHistorySnapshot(m_tx_history);
|
||||
m_tx_history_mutex.unlock();
|
||||
}
|
||||
notifyRefresh(!m_balance_changed && refresh_running);
|
||||
m_balance_changed = false;
|
||||
}
|
||||
|
||||
void Wallet::handleReorgEvent(uint64_t at_block_height) {
|
||||
m_balance_changed = true;
|
||||
}
|
||||
|
||||
void Wallet::handleMoneyEvent(uint64_t at_block_height) {
|
||||
m_balance_changed = true;
|
||||
}
|
||||
|
||||
void Wallet::notifyRefresh(bool debounce) {
|
||||
static std::chrono::steady_clock::time_point last_time;
|
||||
// If debouncing is requested and the blockchain height is a multiple of 100, it limits
|
||||
// the notifications to once every 200 ms.
|
||||
if (debounce) {
|
||||
if (m_blockchain_height % 100 == 0) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now - last_time >= 200.ms) {
|
||||
last_time = now;
|
||||
debounce = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
last_time = std::chrono::steady_clock::now();
|
||||
}
|
||||
if (!debounce) {
|
||||
m_callback.callVoidMethod(getJniEnv(), WalletNative_onRefresh,
|
||||
m_blockchain_height, m_balance_changed);
|
||||
}
|
||||
}
|
||||
|
||||
Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
|
||||
@ -170,7 +367,7 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
|
||||
m_wallet.set_refresh_from_block_height(m_restore_height);
|
||||
try {
|
||||
// refresh() will block until stop() is called or it syncs successfully.
|
||||
m_wallet.refresh(false);
|
||||
m_wallet.refresh(false /* trusted_daemon */);
|
||||
if (!m_wallet.stopped()) {
|
||||
m_wallet.stop();
|
||||
ret = Status::OK;
|
||||
@ -190,9 +387,8 @@ Wallet::Status Wallet::nonReentrantRefresh(bool skip_coinbase) {
|
||||
ret = Status::INTERRUPTED;
|
||||
}
|
||||
m_refresh_running.store(false);
|
||||
m_blockchain_height = m_wallet.get_blockchain_current_height();
|
||||
// Always notify the last block height.
|
||||
callOnRefresh(false);
|
||||
// Ensure the latest block and pool state are consistently processed.
|
||||
handleNewBlock(m_wallet.get_blockchain_current_height(), false);
|
||||
return ret;
|
||||
}
|
||||
|
||||
@ -214,6 +410,7 @@ auto Wallet::suspendRefreshAndRunLocked(T block) -> decltype(block()) {
|
||||
m_callback.callVoidMethod(env, WalletNative_onSuspendRefresh, false);
|
||||
m_refresh_cond.notify_one();
|
||||
}
|
||||
// Call the lambda and release the mutex upon completion.
|
||||
return block();
|
||||
}
|
||||
|
||||
@ -347,7 +544,7 @@ Java_im_molly_monero_WalletNative_nativeSetRefreshSince(
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress(
|
||||
Java_im_molly_monero_WalletNative_nativeGetAccountPrimaryAddress(
|
||||
JNIEnv* env,
|
||||
jobject thiz,
|
||||
jlong handle) {
|
||||
@ -356,47 +553,54 @@ Java_im_molly_monero_WalletNative_nativeGetPrimaryAccountAddress(
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jlong JNICALL
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_im_molly_monero_WalletNative_nativeGetCurrentBlockchainHeight(
|
||||
JNIEnv* env,
|
||||
jobject thiz,
|
||||
jlong handle) {
|
||||
auto* wallet = reinterpret_cast<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();
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
25
lib/android/src/main/kotlin/im/molly/monero/Block.kt
Normal file
25
lib/android/src/main/kotlin/im/molly/monero/Block.kt
Normal 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())
|
108
lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt
Normal file
108
lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
14
lib/android/src/main/kotlin/im/molly/monero/Enote.kt
Normal file
14
lib/android/src/main/kotlin/im/molly/monero/Enote.kt
Normal 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" }
|
||||
}
|
||||
}
|
@ -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" }
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
package im.molly.monero
|
||||
|
||||
enum class ProtocolInfo
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
||||
|
44
lib/android/src/main/kotlin/im/molly/monero/Transaction.kt
Normal file
44
lib/android/src/main/kotlin/im/molly/monero/Transaction.kt
Normal 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,
|
||||
)
|
@ -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(
|
||||
|
@ -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)
|
||||
|
153
lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt
Normal file
153
lib/android/src/main/kotlin/im/molly/monero/internal/TxInfo.kt
Normal 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!!),
|
||||
)
|
@ -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
|
@ -61,9 +61,10 @@ object Decoder {
|
||||
out.position(newOutPos)
|
||||
}
|
||||
|
||||
private fun findOutputBlockSize(blockSize: Int): Int = blockSizes[blockSize].also {
|
||||
require(it >= 0) { "Invalid block size" }
|
||||
}
|
||||
private fun findOutputBlockSize(blockSize: Int): Int =
|
||||
blockSizes[blockSize].also {
|
||||
require(it >= 0) { "Invalid block size" }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.decodeBase58(): ByteArray =
|
||||
|
@ -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) }
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user