lib: add per-account balance and account listing to Ledger

This commit is contained in:
Oscar Mira 2024-03-03 12:35:53 +01:00
parent 6cc43d2502
commit ada39f13e8
12 changed files with 161 additions and 92 deletions

View File

@ -6,5 +6,5 @@ import im.molly.monero.internal.TxInfo;
oneway interface IBalanceListener { oneway interface IBalanceListener {
void onBalanceChanged(in List<TxInfo> txHistory, in String[] subAddresses, in BlockchainTime blockchainTime); void onBalanceChanged(in List<TxInfo> txHistory, in String[] subAddresses, in BlockchainTime blockchainTime);
void onRefresh(in BlockchainTime blockchainTime); void onRefresh(in BlockchainTime blockchainTime);
void onAddressCreated(String subAddress); void onSubAddressListUpdated(in String[] subAddresses);
} }

View File

@ -13,6 +13,7 @@ interface IWallet {
oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback); oneway void addDetachedSubAddress(int accountIndex, int subAddressIndex, in IWalletCallbacks callback);
oneway void createAccount(in IWalletCallbacks callback); oneway void createAccount(in IWalletCallbacks callback);
oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback); oneway void createSubAddressForAccount(int accountIndex, in IWalletCallbacks callback);
oneway void getAddressesForAccount(int accountIndex, in IWalletCallbacks callback);
oneway void getAllAddresses(in IWalletCallbacks callback); oneway void getAllAddresses(in IWalletCallbacks callback);
oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback); oneway void resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback);
oneway void cancelRefresh(); oneway void cancelRefresh();

View File

@ -3,8 +3,9 @@ package im.molly.monero;
import im.molly.monero.BlockchainTime; import im.molly.monero.BlockchainTime;
oneway interface IWalletCallbacks { oneway interface IWalletCallbacks {
void onAddressReady(in String[] subAddresses);
void onRefreshResult(in BlockchainTime blockchainTime, int status); void onRefreshResult(in BlockchainTime blockchainTime, int status);
void onCommitResult(boolean success); void onCommitResult(boolean success);
void onSubAddressReady(String subAddress);
void onSubAddressListReceived(in String[] subAddresses);
void onFeesReceived(in long[] fees); void onFeesReceived(in long[] fees);
} }

View File

@ -220,15 +220,17 @@ std::string Wallet::public_address() const {
return account.get_public_address_str(m_wallet.nettype()); return account.get_public_address_str(m_wallet.nettype());
} }
std::vector<std::string> Wallet::formatted_subaddresses() { std::vector<std::string> Wallet::formatted_subaddresses(uint32_t index_major) {
std::lock_guard<std::mutex> lock(m_subaddresses_mutex); std::lock_guard<std::mutex> lock(m_subaddresses_mutex);
std::vector<std::string> ret; std::vector<std::string> ret;
ret.reserve(m_subaddresses.size()); ret.reserve(m_subaddresses.size());
for (const auto& entry: m_subaddresses) { for (const auto& entry: m_subaddresses) {
if (index_major == -1 || index_major == entry.first.major) {
ret.push_back(FormatAccountAddress(entry)); ret.push_back(FormatAccountAddress(entry));
} }
}
return ret; return ret;
} }
@ -745,9 +747,15 @@ JNIEXPORT jobjectArray JNICALL
Java_im_molly_monero_WalletNative_nativeGetSubAddresses( Java_im_molly_monero_WalletNative_nativeGetSubAddresses(
JNIEnv* env, JNIEnv* env,
jobject thiz, jobject thiz,
jint sub_address_major,
jlong handle) { jlong handle) {
auto* wallet = reinterpret_cast<Wallet*>(handle); auto* wallet = reinterpret_cast<Wallet*>(handle);
return NativeToJavaStringArray(env, wallet->formatted_subaddresses()); try {
auto subaddresses = wallet->formatted_subaddresses(sub_address_major);
return NativeToJavaStringArray(env, subaddresses);
} catch (error::account_index_outofbound& e) {
return NativeToJavaStringArray(env, {});
}
} }
extern "C" extern "C"

View File

@ -112,7 +112,7 @@ class Wallet : i_wallet2_callback {
std::vector<uint64_t> fetchBaseFeeEstimate(); std::vector<uint64_t> fetchBaseFeeEstimate();
std::string public_address() const; std::string public_address() const;
std::vector<std::string> formatted_subaddresses(); std::vector<std::string> formatted_subaddresses(uint32_t index_major = -1);
uint32_t current_blockchain_height() const { return static_cast<uint32_t>(m_last_block_height); } uint32_t current_blockchain_height() const { return static_cast<uint32_t>(m_last_block_height); }
uint64_t current_blockchain_timestamp() const { return m_last_block_timestamp; } uint64_t current_blockchain_timestamp() const { return m_last_block_timestamp; }

View File

@ -49,10 +49,3 @@ data class AccountAddress(
override fun toString(): String = "$accountIndex/$subAddressIndex/$publicAddress" override fun toString(): String = "$accountIndex/$subAddressIndex/$publicAddress"
} }
fun Iterable<AccountAddress>.findByIndexes(
accountIndex: Int,
subAddressIndex: Int,
): AccountAddress? {
return find { it.accountIndex == accountIndex && it.subAddressIndex == subAddressIndex }
}

View File

@ -1,29 +1,17 @@
package im.molly.monero package im.molly.monero
//import im.molly.monero.proto.LedgerProto
data class Ledger( data class Ledger(
val publicAddress: PublicAddress, val publicAddress: PublicAddress,
val accountAddresses: Set<AccountAddress>, val indexedAccounts: List<WalletAccount>,
val transactionById: Map<String, Transaction>, val transactionById: Map<String, Transaction>,
val enotes: Set<TimeLocked<Enote>>, val enoteSet: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime, val checkedAt: BlockchainTime,
) { ) {
val transactions get() = transactionById.values val transactions: Collection<Transaction>
get() = transactionById.values
val balance: Balance = enotes.calculateBalance() fun getBalance(): Balance = enoteSet.calculateBalance()
// companion object { fun getBalanceForAccount(accountIndex: Int): Balance =
// fun fromProto(proto: LedgerProto) = Ledger( enoteSet.calculateBalance { it.accountIndex == accountIndex }
// publicAddress = PublicAddress.base58(proto.publicAddress),
// txOuts = proto.ownedTxOutsList.map { OwnedTxOut.fromProto(it) },
// checkedAtBlockHeight = proto.blockHeight,
// )
// }
//
// fun proto(): LedgerProto = LedgerProto.newBuilder()
// .setPublicAddress(publicAddress.base58)
// .addAllOwnedTxOuts(txOuts.map { it.proto() })
// .setBlockHeight(checkedAtBlockHeight)
// .build()
} }

View File

@ -27,54 +27,90 @@ class MoneroWallet internal constructor(
var dataStore by storageAdapter::dataStore var dataStore by storageAdapter::dataStore
suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress = // suspend fun addDetachedSubAddress(accountIndex: Int, subAddressIndex: Int): AccountAddress =
suspendCancellableCoroutine { continuation -> // suspendCancellableCoroutine { continuation ->
wallet.addDetachedSubAddress( // wallet.addDetachedSubAddress(
accountIndex, // accountIndex,
subAddressIndex, // subAddressIndex,
object : BaseWalletCallbacks() { // object : BaseWalletCallbacks() {
override fun onAddressReady(subAddresses: Array<String>) { // override fun onSubAddressReady(subAddress: String) {
val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) // continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {}
continuation.resume(accountAddress) {} // }
} // })
}) // }
}
suspend fun createAccount(): AccountAddress = suspend fun createAccount(): WalletAccount =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
wallet.createAccount(object : BaseWalletCallbacks() { wallet.createAccount(object : BaseWalletCallbacks() {
override fun onAddressReady(subAddresses: Array<String>) { override fun onSubAddressReady(subAddress: String) {
val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0]) val primaryAddress = AccountAddress.parseWithIndexes(subAddress)
continuation.resume(accountAddress) {} continuation.resume(
WalletAccount(
addresses = listOf(primaryAddress),
accountIndex = primaryAddress.accountIndex,
)
) {}
} }
}) })
} }
/**
* @throws NoSuchAccountException
*/
suspend fun createSubAddressForAccount(accountIndex: Int = 0): AccountAddress = suspend fun createSubAddressForAccount(accountIndex: Int = 0): AccountAddress =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
wallet.createSubAddressForAccount(accountIndex, object : BaseWalletCallbacks() { wallet.createSubAddressForAccount(accountIndex, object : BaseWalletCallbacks() {
override fun onAddressReady(subAddresses: Array<String>) { override fun onSubAddressReady(subAddress: String) {
if (subAddresses.isEmpty()) { continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {}
throw NoSuchAccountException(accountIndex)
}
val accountAddress = AccountAddress.parseWithIndexes(subAddresses[0])
continuation.resume(accountAddress) {}
} }
}) })
} }
suspend fun getAllAddresses(): Set<AccountAddress> = /**
* @throws NoSuchAccountException
*/
suspend fun findUnusedSubAddress(accountIndex: Int = 0): AccountAddress? {
val ledger = ledger().first()
val transactions = ledger.transactions
val account = ledger.indexedAccounts.getOrNull(accountIndex)
?: throw NoSuchAccountException(accountIndex)
return account.addresses.firstOrNull { !it.isAddressUsed(transactions) }
}
/**
* @throws NoSuchAccountException
*/
suspend fun getAccount(accountIndex: Int = 0): WalletAccount =
suspendCancellableCoroutine { continuation ->
wallet.getAddressesForAccount(accountIndex, object : BaseWalletCallbacks() {
override fun onSubAddressListReceived(subAddresses: Array<String>) {
val accounts = parseAndAggregateAddresses(subAddresses)
continuation.resume(accounts.single()) {}
}
})
}
suspend fun getAllAccounts(): List<WalletAccount> =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
wallet.getAllAddresses(object : BaseWalletCallbacks() { wallet.getAllAddresses(object : BaseWalletCallbacks() {
override fun onAddressReady(subAddresses: Array<String>) { override fun onSubAddressListReceived(subAddresses: Array<String>) {
continuation.resume(subAddresses.toAccountAddresses()) {} val accounts = parseAndAggregateAddresses(subAddresses)
continuation.resume(accounts) {}
} }
}) })
} }
private fun Array<String>.toAccountAddresses(): Set<AccountAddress> { private fun parseAndAggregateAddresses(subAddresses: Array<String>): List<WalletAccount> =
return map { AccountAddress.parseWithIndexes(it) }.toSet() subAddresses.map { AccountAddress.parseWithIndexes(it) }
.groupBy { it.accountIndex }
.map { (index, addresses) ->
WalletAccount(
addresses = addresses,
accountIndex = index,
)
} }
.sortedBy { it.accountIndex }
/** /**
* A [Flow] of ledger changes. * A [Flow] of ledger changes.
@ -88,16 +124,16 @@ class MoneroWallet internal constructor(
subAddresses: Array<String>, subAddresses: Array<String>,
blockchainTime: BlockchainTime, blockchainTime: BlockchainTime,
) { ) {
val accountAddresses = subAddresses.toAccountAddresses() val indexedAccounts = parseAndAggregateAddresses(subAddresses)
val (txById, enotes) = txHistory.consolidateTransactions( val (txById, enotes) = txHistory.consolidateTransactions(
accountAddresses = accountAddresses, accounts = indexedAccounts,
blockchainContext = blockchainTime, blockchainContext = blockchainTime,
) )
val ledger = Ledger( val ledger = Ledger(
publicAddress = publicAddress, publicAddress = publicAddress,
accountAddresses = accountAddresses, indexedAccounts = indexedAccounts,
transactionById = txById, transactionById = txById,
enotes = enotes, enoteSet = enotes,
checkedAt = blockchainTime, checkedAt = blockchainTime,
) )
sendLedger(ledger) sendLedger(ledger)
@ -107,11 +143,10 @@ class MoneroWallet internal constructor(
sendLedger(lastKnownLedger.copy(checkedAt = blockchainTime)) sendLedger(lastKnownLedger.copy(checkedAt = blockchainTime))
} }
override fun onAddressCreated(subAddress: String) { override fun onSubAddressListUpdated(subAddresses: Array<String>) {
val addressSet = lastKnownLedger.accountAddresses.toMutableSet() val accountsUpdated = parseAndAggregateAddresses(subAddresses)
val accountAddress = AccountAddress.parseWithIndexes(subAddress) if (lastKnownLedger.indexedAccounts != accountsUpdated) {
if (addressSet.add(accountAddress)) { sendLedger(lastKnownLedger.copy(indexedAccounts = accountsUpdated))
sendLedger(lastKnownLedger.copy(accountAddresses = addressSet))
} }
} }
@ -205,12 +240,14 @@ class NoSuchAccountException(private val accountIndex: Int) : NoSuchElementExcep
} }
private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() { private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() {
override fun onAddressReady(subAddresses: Array<String>) = Unit
override fun onRefreshResult(blockchainTime: BlockchainTime, status: Int) = Unit override fun onRefreshResult(blockchainTime: BlockchainTime, status: Int) = Unit
override fun onCommitResult(success: Boolean) = Unit override fun onCommitResult(success: Boolean) = Unit
override fun onSubAddressReady(subAddress: String) = Unit
override fun onSubAddressListReceived(subAddresses: Array<String>) = Unit
override fun onFeesReceived(fees: LongArray?) = Unit override fun onFeesReceived(fees: LongArray?) = Unit
} }

View File

@ -13,6 +13,9 @@ sealed interface PublicAddress : Parcelable {
fun isSubAddress(): Boolean fun isSubAddress(): Boolean
companion object { companion object {
/**
* @throws InvalidAddress
*/
fun parse(addressString: String): PublicAddress { fun parse(addressString: String): PublicAddress {
val decoded = try { val decoded = try {
addressString.decodeBase58() addressString.decodeBase58()
@ -39,7 +42,9 @@ sealed interface PublicAddress : Parcelable {
} }
} }
class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause) // TODO: Extend ParseException
class InvalidAddress(message: String, cause: Throwable? = null) :
IllegalArgumentException(message, cause)
@Parcelize @Parcelize
data class StandardAddress( data class StandardAddress(

View File

@ -0,0 +1,15 @@
package im.molly.monero
data class WalletAccount(
val addresses: List<AccountAddress>,
val accountIndex: Int,
)
fun Iterable<WalletAccount>.findAddressByIndex(
accountIndex: Int,
subAddressIndex: Int = 0,
): AccountAddress? {
return flatMap { it.addresses }.find {
it.accountIndex == accountIndex && it.subAddressIndex == subAddressIndex
}
}

View File

@ -122,10 +122,13 @@ internal class WalletNative private constructor(
val currentBalance: Balance val currentBalance: Balance
get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance() get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance()
val subAddresses: Array<String> private fun getSubAddresses(accountIndex: Int? = null): Array<String> {
get() = nativeGetSubAddresses(handle) return nativeGetSubAddresses(accountIndex ?: -1, handle)
}
private fun txHistorySnapshot(): List<TxInfo> = nativeGetTxHistory(handle).toList() private fun getTxHistorySnapshot(): List<TxInfo> {
return nativeGetTxHistory(handle).toList()
}
@GuardedBy("listenersLock") @GuardedBy("listenersLock")
private val balanceListeners = mutableSetOf<IBalanceListener>() private val balanceListeners = mutableSetOf<IBalanceListener>()
@ -224,9 +227,12 @@ internal class WalletNative private constructor(
* Also replays the last known balance whenever a new listener registers. * Also replays the last known balance whenever a new listener registers.
*/ */
override fun addBalanceListener(listener: IBalanceListener) { override fun addBalanceListener(listener: IBalanceListener) {
val txHistory = getTxHistorySnapshot()
val subAddresses = getSubAddresses()
balanceListenersLock.withLock { balanceListenersLock.withLock {
balanceListeners.add(listener) balanceListeners.add(listener)
listener.onBalanceChanged(txHistorySnapshot(), subAddresses, currentBlockchainTime) listener.onBalanceChanged(txHistory, subAddresses, currentBlockchainTime)
} }
} }
@ -249,8 +255,8 @@ internal class WalletNative private constructor(
override fun createAccount(callback: IWalletCallbacks?) { override fun createAccount(callback: IWalletCallbacks?) {
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
val subAddress = nativeCreateSubAddressAccount(handle) val primaryAddress = nativeCreateSubAddressAccount(handle)
notifyAddressCreation(subAddress, callback) notifyAddressCreation(primaryAddress, callback)
} }
} }
@ -260,23 +266,37 @@ internal class WalletNative private constructor(
if (subAddress != null) { if (subAddress != null) {
notifyAddressCreation(subAddress, callback) notifyAddressCreation(subAddress, callback)
} else { } else {
callback?.onAddressReady(emptyArray()) TODO()
} }
} }
} }
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) { private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) {
balanceListenersLock.withLock { balanceListenersLock.withLock {
if (balanceListeners.isNotEmpty()) {
val subAddresses = getSubAddresses()
balanceListeners.forEach { listener -> balanceListeners.forEach { listener ->
listener.onAddressCreated(subAddress) listener.onSubAddressListUpdated(subAddresses)
}
}
}
callback?.onSubAddressReady(subAddress)
}
override fun getAddressesForAccount(accountIndex: Int, callback: IWalletCallbacks) {
scope.launch(ioDispatcher) {
val accountSubAddresses = getSubAddresses(accountIndex)
if (accountSubAddresses.isNotEmpty()) {
callback.onSubAddressListReceived(accountSubAddresses)
} else {
TODO()
} }
} }
callback?.onAddressReady(arrayOf(subAddress))
} }
override fun getAllAddresses(callback: IWalletCallbacks) { override fun getAllAddresses(callback: IWalletCallbacks) {
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
callback.onAddressReady(subAddresses) callback.onSubAddressListReceived(getSubAddresses())
} }
} }
@ -286,7 +306,8 @@ internal class WalletNative private constructor(
if (balanceListeners.isNotEmpty()) { if (balanceListeners.isNotEmpty()) {
val blockchainTime = network.blockchainTime(height, timestamp) val blockchainTime = network.blockchainTime(height, timestamp)
val call = if (balanceChanged) { val call = if (balanceChanged) {
val txHistory = txHistorySnapshot() val txHistory = getTxHistorySnapshot()
val subAddresses = getSubAddresses()
fun(listener: IBalanceListener) { fun(listener: IBalanceListener) {
listener.onBalanceChanged(txHistory, subAddresses, blockchainTime) listener.onBalanceChanged(txHistory, subAddresses, blockchainTime)
} }
@ -396,7 +417,7 @@ internal class WalletNative private constructor(
private external fun nativeGetPublicAddress(handle: Long): String private external fun nativeGetPublicAddress(handle: Long): String
private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int
private external fun nativeGetCurrentBlockchainTimestamp(handle: Long): Long private external fun nativeGetCurrentBlockchainTimestamp(handle: Long): Long
private external fun nativeGetSubAddresses(handle: Long): Array<String> private external fun nativeGetSubAddresses(subAddressMajor: Int, handle: Long): Array<String>
private external fun nativeGetTxHistory(handle: Long): Array<TxInfo> private external fun nativeGetTxHistory(handle: Long): Array<TxInfo>
private external fun nativeFetchBaseFeeEstimate(handle: Long): LongArray private external fun nativeFetchBaseFeeEstimate(handle: Long): LongArray
private external fun nativeLoad(handle: Long, fd: Int): Boolean private external fun nativeLoad(handle: Long, fd: Int): Boolean

View File

@ -1,12 +1,12 @@
package im.molly.monero.internal package im.molly.monero.internal
import android.os.Parcelable import android.os.Parcelable
import im.molly.monero.AccountAddress
import im.molly.monero.BlockHeader import im.molly.monero.BlockHeader
import im.molly.monero.BlockchainTime import im.molly.monero.BlockchainTime
import im.molly.monero.CalledByNative import im.molly.monero.CalledByNative
import im.molly.monero.Enote import im.molly.monero.Enote
import im.molly.monero.HashDigest import im.molly.monero.HashDigest
import im.molly.monero.WalletAccount
import im.molly.monero.MoneroAmount import im.molly.monero.MoneroAmount
import im.molly.monero.PaymentDetail import im.molly.monero.PaymentDetail
import im.molly.monero.PublicAddress import im.molly.monero.PublicAddress
@ -15,7 +15,7 @@ import im.molly.monero.TimeLocked
import im.molly.monero.Transaction import im.molly.monero.Transaction
import im.molly.monero.TxState import im.molly.monero.TxState
import im.molly.monero.UnlockTime import im.molly.monero.UnlockTime
import im.molly.monero.findByIndexes import im.molly.monero.findAddressByIndex
import im.molly.monero.isBlockHeightInRange import im.molly.monero.isBlockHeightInRange
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.time.Instant import java.time.Instant
@ -62,12 +62,12 @@ internal data class TxInfo @CalledByNative constructor(
} }
internal fun List<TxInfo>.consolidateTransactions( internal fun List<TxInfo>.consolidateTransactions(
accountAddresses: Set<AccountAddress>, accounts: List<WalletAccount>,
blockchainContext: BlockchainTime, blockchainContext: BlockchainTime,
): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> { ): Pair<Map<String, Transaction>, Set<TimeLocked<Enote>>> {
// Extract enotes from incoming transactions // Extract enotes from incoming transactions
val allEnotes = val allEnotes =
filter { it.incoming }.map { it.toEnote(blockchainContext.height, accountAddresses) } filter { it.incoming }.map { it.toEnote(blockchainContext.height, accounts) }
val enoteByTxId = allEnotes.groupBy { enote -> enote.sourceTxId!! } val enoteByTxId = allEnotes.groupBy { enote -> enote.sourceTxId!! }
@ -152,9 +152,9 @@ private fun List<TxInfo>.determineTxState(): TxState {
} }
} }
private fun TxInfo.toEnote(blockchainHeight: Int, accountAddresses: Set<AccountAddress>): Enote { private fun TxInfo.toEnote(blockchainHeight: Int, accounts: List<WalletAccount>): Enote {
val ownerAddress = accountAddresses.findByIndexes(subAddressMajor, subAddressMinor) val ownerAddress = accounts.findAddressByIndex(subAddressMajor, subAddressMinor)
?: error("Failed to find account address for: $subAddressMajor/$subAddressMinor") ?: error("Failed to find subaddress: $subAddressMajor/$subAddressMinor")
val calculatedAge = if (height > 0) (blockchainHeight - height + 1) else 0 val calculatedAge = if (height > 0) (blockchainHeight - height + 1) else 0
return Enote( return Enote(