Shielded address rotation

This commit is contained in:
Milan Cerovsky 2025-05-15 11:49:18 +02:00
parent 52a5fd53ff
commit 72534e42f4
9 changed files with 184 additions and 171 deletions

View File

@ -11,6 +11,9 @@ private const val DECIMALS_SHORT = 3
private const val MIN_ZATOSHI_FOR_DOTS_SHORT = Zatoshi.ZATOSHI_PER_ZEC / 1000
val Zatoshi.Companion.ZERO: Zatoshi
get() = Zatoshi(0)
fun Zatoshi.toZecStringFull() =
convertZatoshiToZecString(
maxDecimals = DECIMALS_MAX_LONG,

View File

@ -44,6 +44,8 @@ import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToErrorUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToReceiveUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToRequestShieldedUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToTaxExportUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToWalletBackupUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
@ -192,4 +194,6 @@ val useCaseModule =
singleOf(::NavigateToErrorUseCase)
factoryOf(::RescanQrUseCase)
factoryOf(::ShieldFundsMessageUseCase)
factoryOf(::NavigateToReceiveUseCase)
factoryOf(::NavigateToRequestShieldedUseCase)
}

View File

@ -1,14 +1,18 @@
package co.electriccoin.zcash.ui.common.datasource
import android.content.Context
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.AccountImportSetup
import cash.z.ecc.android.sdk.model.AccountPurpose
import cash.z.ecc.android.sdk.model.AccountUuid
import cash.z.ecc.android.sdk.model.UnifiedAddressRequest
import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.Zip32AccountIndex
import cash.z.ecc.sdk.extension.ZERO
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.SaplingInfo
@ -18,12 +22,14 @@ import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.provider.SelectedAccountUUIDProvider
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import co.electriccoin.zcash.ui.design.util.combineToFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -34,8 +40,10 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
@ -61,6 +69,8 @@ interface AccountDataSource {
seedFingerprint: String,
index: Long
): Account
suspend fun requestNextShieldedAddress()
}
class AccountDataSourceImpl(
@ -70,119 +80,54 @@ class AccountDataSourceImpl(
) : AccountDataSource {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val requestNextShieldedAddressPipeline = MutableSharedFlow<AccountUuid>()
@OptIn(ExperimentalCoroutinesApi::class)
private val internalAccounts: Flow<List<InternalAccountWithBalances>?> =
override val allAccounts: StateFlow<List<WalletAccount>?> =
synchronizerProvider
.synchronizer
.flatMapLatest { synchronizer ->
synchronizer
?.accountsFlow
?.map { accounts ->
accounts?.map { account ->
if (account.keySource == KEYSTONE_KEYSOURCE) {
InternalAccountWithAddresses(
sdkAccount = account,
unifiedAddress =
WalletAddress.Unified.new(synchronizer.getUnifiedAddress(account)),
saplingAddress = null,
transparentAddress =
WalletAddress.Transparent.new(synchronizer.getTransparentAddress(account)),
)
} else {
InternalAccountWithAddresses(
sdkAccount = account,
unifiedAddress =
WalletAddress.Unified.new(synchronizer.getUnifiedAddress(account)),
saplingAddress =
WalletAddress.Sapling.new(synchronizer.getSaplingAddress(account)),
transparentAddress =
WalletAddress.Transparent.new(synchronizer.getTransparentAddress(account)),
)
}
}
}?.flatMapLatest { accountsWithAddresses ->
if (accountsWithAddresses == null) {
flowOf(null)
} else {
synchronizer.walletBalances.map { walletBalances ->
if (walletBalances == null) {
null
} else {
accountsWithAddresses.map { accountWithAddresses ->
val balance =
walletBalances[accountWithAddresses.sdkAccount.accountUuid]
?.flatMapLatest { allSdkAccounts ->
allSdkAccounts
?.map { sdkAccount ->
combine(
observeUnified(synchronizer, sdkAccount),
observeTransparent(synchronizer, sdkAccount),
observeSapling(synchronizer, sdkAccount),
observeIsSelected(sdkAccount, allSdkAccounts),
) { unified, transparent, sapling, isSelected ->
when (sdkAccount.keySource?.lowercase()) {
KEYSTONE_KEYSOURCE ->
KeystoneAccount(
sdkAccount = sdkAccount,
unified = unified,
transparent = transparent,
isSelected = isSelected,
)
InternalAccountWithBalances(
sdkAccount = accountWithAddresses.sdkAccount,
unifiedAddress = accountWithAddresses.unifiedAddress,
saplingAddress = accountWithAddresses.saplingAddress,
transparentAddress = accountWithAddresses.transparentAddress,
orchardBalance = balance?.orchard ?: createEmptyWalletBalance(),
transparentBalance = balance?.unshielded ?: Zatoshi.ZERO,
saplingBalance = balance?.sapling,
)
else ->
ZashiAccount(
sdkAccount = sdkAccount,
unified = unified,
transparent = transparent,
sapling = sapling!!,
isSelected = isSelected,
)
}
}
}
}
}?.retryWhen { _, attempt ->
?.combineToFlow() ?: flowOf(null)
}
?.retryWhen { _, attempt ->
emit(null)
delay(attempt.coerceAtMost(RETRY_DELAY).seconds)
true
}
?: flowOf(null)
}.flowOn(Dispatchers.IO)
override val allAccounts: StateFlow<List<WalletAccount>?> =
combine(
internalAccounts,
selectedAccountUUIDProvider.uuid,
) { accounts, uuid ->
accounts
?.map { account ->
when (account.sdkAccount.keySource?.lowercase()) {
KEYSTONE_KEYSOURCE ->
KeystoneAccount(
sdkAccount = account.sdkAccount,
unified =
UnifiedInfo(
address = account.unifiedAddress,
balance = account.orchardBalance
),
transparent =
TransparentInfo(
address = account.transparentAddress,
balance = account.transparentBalance
),
isSelected = account.sdkAccount.accountUuid == uuid || accounts.size == 1,
)
else ->
ZashiAccount(
sdkAccount = account.sdkAccount,
unified =
UnifiedInfo(
address = account.unifiedAddress,
balance = account.orchardBalance
),
transparent =
TransparentInfo(
address = account.transparentAddress,
balance = account.transparentBalance
),
sapling =
SaplingInfo(
address = account.saplingAddress!!,
balance = account.saplingBalance!!
),
isSelected =
uuid == null ||
account.sdkAccount.accountUuid == uuid ||
accounts.size == 1,
)
}
}?.sortedDescending()
}.flowOn(Dispatchers.IO)
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
@ -201,20 +146,11 @@ class AccountDataSourceImpl(
account?.filterIsInstance<ZashiAccount>()?.firstOrNull()
}.distinctUntilChanged()
override suspend fun getAllAccounts() =
withContext(Dispatchers.IO) {
allAccounts.filterNotNull().first()
}
override suspend fun getAllAccounts() = withContext(Dispatchers.IO) { allAccounts.filterNotNull().first() }
override suspend fun getSelectedAccount() =
withContext(Dispatchers.IO) {
selectedAccount.filterNotNull().first()
}
override suspend fun getSelectedAccount() = withContext(Dispatchers.IO) { selectedAccount.filterNotNull().first() }
override suspend fun getZashiAccount() =
withContext(Dispatchers.IO) {
zashiAccount.filterNotNull().first()
}
override suspend fun getZashiAccount() = withContext(Dispatchers.IO) { zashiAccount.filterNotNull().first() }
override suspend fun selectAccount(account: Account) {
withContext(Dispatchers.IO) {
@ -246,34 +182,76 @@ class AccountDataSourceImpl(
),
)
}
override suspend fun requestNextShieldedAddress() {
scope
.launch {
requestNextShieldedAddressPipeline.emit(getSelectedAccount().sdkAccount.accountUuid)
}
.join()
}
private fun observeIsSelected(sdkAccount: Account, allAccounts: List<Account>) = selectedAccountUUIDProvider
.uuid
.map { uuid ->
when (sdkAccount.keySource?.lowercase()) {
KEYSTONE_KEYSOURCE -> sdkAccount.accountUuid == uuid || allAccounts.size == 1
else -> uuid == null || sdkAccount.accountUuid == uuid || allAccounts.size == 1
}
}
private suspend fun observeUnified(synchronizer: Synchronizer, sdkAccount: Account): Flow<UnifiedInfo> {
return combine(
requestNextShieldedAddressPipeline
.onStart { emit(sdkAccount.accountUuid) }
.map {
WalletAddress.Unified.new(
synchronizer.getCustomUnifiedAddress(sdkAccount, UnifiedAddressRequest.SHIELDED)
)
},
synchronizer.walletBalances
) { address, balances ->
val balance = balances?.get(sdkAccount.accountUuid)
UnifiedInfo(
address = address,
balance = balance?.orchard ?: createEmptyWalletBalance()
)
}
}
private suspend fun observeTransparent(synchronizer: Synchronizer, sdkAccount: Account): Flow<TransparentInfo> {
val address = WalletAddress.Transparent.new(synchronizer.getTransparentAddress(sdkAccount))
return synchronizer.walletBalances.map {
val balance = it?.get(sdkAccount.accountUuid)
TransparentInfo(
address = address,
balance = balance?.unshielded ?: Zatoshi.ZERO
)
}
}
private suspend fun observeSapling(synchronizer: Synchronizer, sdkAccount: Account): Flow<SaplingInfo?> {
return if (sdkAccount.keySource == KEYSTONE_KEYSOURCE) {
flowOf(null)
} else {
val address = WalletAddress.Sapling.new(synchronizer.getSaplingAddress(sdkAccount))
synchronizer.walletBalances.map {
val balance = it?.get(sdkAccount.accountUuid)
SaplingInfo(
address = address,
balance = balance?.sapling ?: createEmptyWalletBalance()
)
}
}
}
private fun createEmptyWalletBalance() =
WalletBalance(
available = Zatoshi.ZERO,
changePending = Zatoshi.ZERO,
valuePending = Zatoshi.ZERO,
)
}
private fun createEmptyWalletBalance() =
WalletBalance(
available = Zatoshi.ZERO,
changePending = Zatoshi.ZERO,
valuePending = Zatoshi.ZERO,
)
private val Zatoshi.Companion.ZERO: Zatoshi
get() = Zatoshi(0)
private data class InternalAccountWithAddresses(
val sdkAccount: Account,
val unifiedAddress: WalletAddress.Unified,
val saplingAddress: WalletAddress.Sapling?,
val transparentAddress: WalletAddress.Transparent,
)
private data class InternalAccountWithBalances(
val sdkAccount: Account,
val unifiedAddress: WalletAddress.Unified,
val saplingAddress: WalletAddress.Sapling?,
val transparentAddress: WalletAddress.Transparent,
val saplingBalance: WalletBalance?,
val orchardBalance: WalletBalance,
val transparentBalance: Zatoshi,
)
private const val RETRY_DELAY = 3L
private const val KEYSTONE_KEYSOURCE = "keystone"

View File

@ -0,0 +1,22 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRequest
import co.electriccoin.zcash.ui.common.repository.BiometricsCancelledException
import co.electriccoin.zcash.ui.common.repository.BiometricsFailureException
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs
import co.electriccoin.zcash.ui.screen.receive.Receive
class NavigateToReceiveUseCase(
private val navigationRouter: NavigationRouter,
private val accountDataSource: AccountDataSource
) {
suspend operator fun invoke() {
accountDataSource.requestNextShieldedAddress()
navigationRouter.forward(Receive)
}
}

View File

@ -0,0 +1,18 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
class NavigateToRequestShieldedUseCase(
private val navigationRouter: NavigationRouter,
private val accountDataSource: AccountDataSource
) {
suspend operator fun invoke(requestNewAddress: Boolean = true) {
if (requestNewAddress) {
accountDataSource.requestNextShieldedAddress()
}
navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}")
}
}

View File

@ -18,6 +18,8 @@ import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToErrorUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToReceiveUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToRequestShieldedUseCase
import co.electriccoin.zcash.ui.common.usecase.ShieldFundsMessageUseCase
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
@ -39,7 +41,6 @@ import co.electriccoin.zcash.ui.screen.home.syncing.WalletSyncingMessageState
import co.electriccoin.zcash.ui.screen.home.updating.WalletUpdatingInfo
import co.electriccoin.zcash.ui.screen.home.updating.WalletUpdatingMessageState
import co.electriccoin.zcash.ui.screen.integrations.DialogIntegrations
import co.electriccoin.zcash.ui.screen.receive.Receive
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
@ -61,9 +62,11 @@ class HomeViewModel(
shieldFundsInfoProvider: ShieldFundsInfoProvider,
private val navigationRouter: NavigationRouter,
private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase,
private val navigateToCoinbase: NavigateToCoinbaseUseCase,
private val shieldFunds: ShieldFundsMessageUseCase,
private val navigateToCoinbase: NavigateToCoinbaseUseCase,
private val navigateToError: NavigateToErrorUseCase,
private val navigateToReceive: NavigateToReceiveUseCase,
private val navigateToRequestShielded: NavigateToRequestShieldedUseCase
) : ViewModel() {
private val messageState =
combine(
@ -234,14 +237,13 @@ class HomeViewModel(
private fun onSendButtonClick() = navigationRouter.forward(Send())
private fun onReceiveButtonClick() = navigationRouter.forward(Receive)
private fun onReceiveButtonClick() = viewModelScope.launch { navigateToReceive() }
private fun onScanButtonClick() = navigationRouter.forward(Scan(ScanFlow.HOMEPAGE))
private fun onBuyClick() = viewModelScope.launch { navigateToCoinbase(replaceCurrentScreen = false) }
private fun onRequestClick() =
navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}")
private fun onRequestClick() = viewModelScope.launch { navigateToRequestShielded() }
private fun onWalletUpdatingMessageClick() = navigationRouter.forward(WalletUpdatingInfo)

View File

@ -46,6 +46,7 @@ import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
@ -140,9 +141,9 @@ private fun AddressPanel(
.wrapContentHeight()
.background(
if (state.isShielded) {
ZashiColors.Utility.Purple.utilityPurple50
ZashiColors.Utility.Purple.utilityPurple50 orDark ZashiColors.Utility.Indigo.utilityIndigo50
} else {
ZashiColors.Utility.Gray.utilityGray50
ZashiColors.Surfaces.bgSecondary
},
RoundedCornerShape(ZashiDimensions.Radius.radius3xl)
).clip(RoundedCornerShape(ZashiDimensions.Radius.radius3xl))
@ -152,7 +153,7 @@ private fun AddressPanel(
Row(modifier = Modifier.fillMaxWidth()) {
Box {
Image(
modifier = Modifier.sizeIn(maxWidth = 34.dp, maxHeight = 34.dp),
modifier = Modifier.size(40.dp),
painter = painterResource(id = state.icon),
contentDescription = null
)
@ -162,7 +163,7 @@ private fun AddressPanel(
Modifier
.size(14.dp)
.align(Alignment.BottomEnd)
.offset(3.5.dp, 3.5.dp),
.offset(1.5.dp, .5.dp),
painter = painterResource(R.drawable.ic_receive_shield),
contentDescription = "",
)
@ -178,9 +179,7 @@ private fun AddressPanel(
style = ZashiTypography.textMd,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(4.dp)
Text(
text = state.subtitle.getValue(),
color = ZashiColors.Text.textTertiary,
@ -191,13 +190,6 @@ private fun AddressPanel(
Spacer(Modifier.width(ZcashTheme.dimens.spacingSmall))
Spacer(modifier = Modifier.weight(1f))
if (state.isShielded) {
Image(
painter = painterResource(id = R.drawable.ic_check_shielded_solid),
contentDescription = null
)
}
}
AnimatedVisibility(visible = state.isExpanded) {
@ -210,13 +202,13 @@ private fun AddressPanel(
) {
val containerColor =
if (state.isShielded) {
ZashiColors.Utility.Purple.utilityPurple100
ZashiColors.Utility.Purple.utilityPurple100 orDark ZashiColors.Utility.Indigo.utilityIndigo100
} else {
ZashiColors.Surfaces.bgTertiary
}
val contentColor =
if (state.isShielded) {
ZashiColors.Utility.Purple.utilityPurple800
ZashiColors.Utility.Purple.utilityPurple800 orDark ZashiColors.Utility.Indigo.utilityIndigo800
} else {
ZashiColors.Text.textPrimary
}
@ -272,16 +264,14 @@ private fun ReceiveIconButton(
.background(containerColor, RoundedCornerShape(ZashiDimensions.Radius.radiusXl))
.clip(RoundedCornerShape(ZashiDimensions.Radius.radiusXl))
.clickable { onClick() }
.padding(ZcashTheme.dimens.spacingMid)
.padding(12.dp)
) {
Icon(
painter = iconPainter,
contentDescription = text,
tint = contentColor
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(4.dp)
Text(
text = text,
color = contentColor,

View File

@ -78,12 +78,7 @@ class ReceiveViewModel(
icon =
when (account) {
is KeystoneAccount -> co.electriccoin.zcash.ui.design.R.drawable.ic_item_keystone
is ZashiAccount ->
if (type == ReceiveAddressType.Unified) {
R.drawable.ic_zec_round_full
} else {
R.drawable.ic_zec_round_stroke
}
is ZashiAccount -> R.drawable.ic_zec_round_full
},
title =
when (account) {

View File

@ -12,6 +12,7 @@ import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.repository.Transaction
import co.electriccoin.zcash.ui.common.usecase.GetTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToRequestShieldedUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class TransactionHistoryWidgetViewModel(
getTransactions: GetTransactionsUseCase,
@ -28,6 +30,7 @@ class TransactionHistoryWidgetViewModel(
private val transactionHistoryMapper: TransactionHistoryMapper,
private val navigationRouter: NavigationRouter,
private val restoreTimestampDataSource: RestoreTimestampDataSource,
private val navigateToRequestShielded: NavigateToRequestShieldedUseCase
) : ViewModel() {
val state =
combine(
@ -88,9 +91,7 @@ class TransactionHistoryWidgetViewModel(
navigationRouter.forward(TransactionHistory)
}
private fun onRequestZecClick() {
navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}")
}
private fun onRequestZecClick() = viewModelScope.launch { navigateToRequestShielded() }
}
private const val MAX_TRANSACTION_COUNT = 5