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 {
void onBalanceChanged(in List<TxInfo> txHistory, in String[] subAddresses, 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 createAccount(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 resumeRefresh(boolean skipCoinbase, in IWalletCallbacks callback);
oneway void cancelRefresh();

View File

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

View File

@ -220,14 +220,16 @@ std::string Wallet::public_address() const {
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::vector<std::string> ret;
ret.reserve(m_subaddresses.size());
for (const auto& entry: m_subaddresses) {
ret.push_back(FormatAccountAddress(entry));
if (index_major == -1 || index_major == entry.first.major) {
ret.push_back(FormatAccountAddress(entry));
}
}
return ret;
@ -745,9 +747,15 @@ JNIEXPORT jobjectArray JNICALL
Java_im_molly_monero_WalletNative_nativeGetSubAddresses(
JNIEnv* env,
jobject thiz,
jint sub_address_major,
jlong 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"

View File

@ -112,7 +112,7 @@ class Wallet : i_wallet2_callback {
std::vector<uint64_t> fetchBaseFeeEstimate();
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); }
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"
}
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
//import im.molly.monero.proto.LedgerProto
data class Ledger(
val publicAddress: PublicAddress,
val accountAddresses: Set<AccountAddress>,
val indexedAccounts: List<WalletAccount>,
val transactionById: Map<String, Transaction>,
val enotes: Set<TimeLocked<Enote>>,
val enoteSet: Set<TimeLocked<Enote>>,
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 fromProto(proto: LedgerProto) = Ledger(
// 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()
fun getBalanceForAccount(accountIndex: Int): Balance =
enoteSet.calculateBalance { it.accountIndex == accountIndex }
}

View File

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

View File

@ -13,6 +13,9 @@ sealed interface PublicAddress : Parcelable {
fun isSubAddress(): Boolean
companion object {
/**
* @throws InvalidAddress
*/
fun parse(addressString: String): PublicAddress {
val decoded = try {
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
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
get() = TODO() // txHistorySnapshot().consolidateTransactions().second.balance()
val subAddresses: Array<String>
get() = nativeGetSubAddresses(handle)
private fun getSubAddresses(accountIndex: Int? = null): Array<String> {
return nativeGetSubAddresses(accountIndex ?: -1, handle)
}
private fun txHistorySnapshot(): List<TxInfo> = nativeGetTxHistory(handle).toList()
private fun getTxHistorySnapshot(): List<TxInfo> {
return nativeGetTxHistory(handle).toList()
}
@GuardedBy("listenersLock")
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.
*/
override fun addBalanceListener(listener: IBalanceListener) {
val txHistory = getTxHistorySnapshot()
val subAddresses = getSubAddresses()
balanceListenersLock.withLock {
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?) {
scope.launch(ioDispatcher) {
val subAddress = nativeCreateSubAddressAccount(handle)
notifyAddressCreation(subAddress, callback)
val primaryAddress = nativeCreateSubAddressAccount(handle)
notifyAddressCreation(primaryAddress, callback)
}
}
@ -260,23 +266,37 @@ internal class WalletNative private constructor(
if (subAddress != null) {
notifyAddressCreation(subAddress, callback)
} else {
callback?.onAddressReady(emptyArray())
TODO()
}
}
}
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) {
balanceListenersLock.withLock {
balanceListeners.forEach { listener ->
listener.onAddressCreated(subAddress)
if (balanceListeners.isNotEmpty()) {
val subAddresses = getSubAddresses()
balanceListeners.forEach { listener ->
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) {
scope.launch(ioDispatcher) {
callback.onAddressReady(subAddresses)
callback.onSubAddressListReceived(getSubAddresses())
}
}
@ -286,7 +306,8 @@ internal class WalletNative private constructor(
if (balanceListeners.isNotEmpty()) {
val blockchainTime = network.blockchainTime(height, timestamp)
val call = if (balanceChanged) {
val txHistory = txHistorySnapshot()
val txHistory = getTxHistorySnapshot()
val subAddresses = getSubAddresses()
fun(listener: IBalanceListener) {
listener.onBalanceChanged(txHistory, subAddresses, blockchainTime)
}
@ -396,7 +417,7 @@ internal class WalletNative private constructor(
private external fun nativeGetPublicAddress(handle: Long): String
private external fun nativeGetCurrentBlockchainHeight(handle: Long): Int
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 nativeFetchBaseFeeEstimate(handle: Long): LongArray
private external fun nativeLoad(handle: Long, fd: Int): Boolean

View File

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