diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index c17d59044..16cbda7eb 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -85,7 +85,6 @@ jobs: - { name: migration_1-restore, grep: "@migration_1" } - { name: migration_2-migration, grep: "@migration_2" } - { name: migration_3-with-passphrase, grep: "@migration_3" } - - { name: migration_4-with-sweep, grep: "@migration_4" } name: e2e-tests - ${{ matrix.rn_version }} - ${{ matrix.scenario.name }} diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index a3cfcef84..9cd948d94 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -117,6 +117,9 @@ data class SettingsData( val coinSelectPreference: CoinSelectionPreference = CoinSelectionPreference.BranchAndBound, val electrumServer: String = Env.electrumServerUrl, val rgsServerUrl: String? = Env.ldkRgsServerUrl, + val selectedAddressType: String = "nativeSegwit", + val addressTypesToMonitor: List = listOf("nativeSegwit"), + val pendingRestoreAddressTypePrune: Boolean = false, ) fun SettingsData.resetPin() = this.copy( diff --git a/app/src/main/java/to/bitkit/models/AddressType.kt b/app/src/main/java/to/bitkit/models/AddressType.kt index 3759011bb..1d2325926 100644 --- a/app/src/main/java/to/bitkit/models/AddressType.kt +++ b/app/src/main/java/to/bitkit/models/AddressType.kt @@ -12,6 +12,7 @@ data class AddressTypeInfo( val shortName: String, val description: String, val example: String, + val shortExample: String, ) @Suppress("unused") @@ -20,32 +21,36 @@ fun AddressType.addressTypeInfo(): AddressTypeInfo = when (this) { path = "m/86'/0'/0'/0/0", name = "Taproot", shortName = "Taproot", - description = "Taproot Address", + description = "Pay-to-Taproot (bc1px...)", example = "(bc1px...)", + shortExample = "bc1p...", ) AddressType.P2WPKH -> AddressTypeInfo( path = "m/84'/0'/0'/0/0", name = "Native Segwit Bech32", shortName = "Native Segwit", - description = "Pay-to-witness-public-key-hash", - example = "(bc1x...)", + description = "Pay-to-witness-public-key-hash (bc1q...)", + example = "(bc1q...)", + shortExample = "bc1q...", ) AddressType.P2SH -> AddressTypeInfo( path = "m/49'/0'/0'/0/0", name = "Nested Segwit", - shortName = "Segwit", - description = "Pay-to-Script-Hash", + shortName = "Nested Segwit", + description = "Pay-to-Script-Hash (3x...)", example = "(3x...)", + shortExample = "3x...", ) AddressType.P2PKH -> AddressTypeInfo( path = "m/44'/0'/0'/0/0", name = "Legacy", shortName = "Legacy", - description = "Pay-to-public-key-hash", + description = "Pay-to-public-key-hash (1x...)", example = "(1x...)", + shortExample = "1x...", ) else -> AddressTypeInfo( @@ -54,6 +59,7 @@ fun AddressType.addressTypeInfo(): AddressTypeInfo = when (this) { shortName = "Unknown", description = "Unknown", example = "", + shortExample = "", ) } @@ -80,3 +86,29 @@ fun AddressType.toDerivationPath( else -> "" } } + +fun AddressType.toSettingsString(): String = when (this) { + AddressType.P2TR -> "taproot" + AddressType.P2WPKH -> "nativeSegwit" + AddressType.P2SH -> "nestedSegwit" + AddressType.P2PKH -> "legacy" + else -> "nativeSegwit" +} + +fun String.toAddressType(): AddressType? = when (this) { + "taproot" -> AddressType.P2TR + "nativeSegwit" -> AddressType.P2WPKH + "nestedSegwit" -> AddressType.P2SH + "legacy" -> AddressType.P2PKH + else -> null +} + +val ALL_ADDRESS_TYPE_STRINGS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") + +fun String.addressTypeFromAddress(): String? = when { + startsWith("bc1p") || startsWith("tb1p") || startsWith("bcrt1p") -> "taproot" + startsWith("bc1") || startsWith("tb1") || startsWith("bcrt1") -> "nativeSegwit" + startsWith("3") || startsWith("2") -> "nestedSegwit" + startsWith("1") || startsWith("m") || startsWith("n") -> "legacy" + else -> null +} diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 07cfc7a23..ec9d671ad 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class BalanceState( val totalOnchainSats: ULong = 0uL, + val channelFundableBalance: ULong = 0uL, val totalLightningSats: ULong = 0uL, val maxSendLightningSats: ULong = 0uL, // Without account routing fees val maxSendOnchainSats: ULong = 0uL, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f32a407fa..fe09f1188 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import com.google.firebase.messaging.FirebaseMessaging +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice @@ -44,6 +45,7 @@ import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain @@ -56,8 +58,11 @@ import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.models.safe +import to.bitkit.models.toAddressType import to.bitkit.models.toCoinSelectAlgorithm import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toSettingsString import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse @@ -113,6 +118,7 @@ class LightningRepo @Inject constructor( private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) private val lifecycleMutex = Mutex() + private val isChangingAddressType = AtomicBoolean(false) init { observeConnectivityForSyncRetry() @@ -628,6 +634,170 @@ class LightningRepo @Inject constructor( } } + suspend fun getBalanceForAddressType(addressType: AddressType): Result = withContext(bgDispatcher) { + executeWhenNodeRunning("getBalanceForAddressType") { + runCatching { + lightningService.getBalanceForAddressType(addressType).totalSats + } + } + } + + suspend fun getChannelFundableBalance(): ULong = withContext(bgDispatcher) { + val settings = settingsStore.data.first() + val selectedType = settings.selectedAddressType.toAddressType() + val monitoredTypes = settings.addressTypesToMonitor.mapNotNull { it.toAddressType() } + val typesToSum = (listOfNotNull(selectedType) + monitoredTypes).distinct().filter { it != AddressType.P2PKH } + + if (typesToSum.isEmpty()) { + return@withContext getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: 0uL + } + + var total = 0uL + for (type in typesToSum) { + val balance = executeWhenNodeRunning("getBalanceForAddressType") { + runCatching { lightningService.getBalanceForAddressType(type).spendableSats } + }.getOrNull() + if (balance == null) { + return@withContext getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: 0uL + } + total = total.safe() + balance.safe() + } + total + } + + suspend fun updateAddressType( + selectedType: String, + monitoredTypes: List, + ): Result = withContext(bgDispatcher) { + if (!isChangingAddressType.compareAndSet(false, true)) { + return@withContext Result.failure(AppError("Address type change already in progress")) + } + + val previousSettings = settingsStore.data.first() + val oldSelected = previousSettings.selectedAddressType + val oldMonitored = previousSettings.addressTypesToMonitor + val addressType = selectedType.toAddressType() ?: AddressType.P2WPKH + + suspend fun rollback() = + settingsStore.update { it.copy(selectedAddressType = oldSelected, addressTypesToMonitor = oldMonitored) } + + runCatching { + settingsStore.update { + it.copy(selectedAddressType = selectedType, addressTypesToMonitor = monitoredTypes) + } + lightningService.setPrimaryAddressType(addressType) + sync().onFailure { Logger.warn("Sync after address type change failed", it, context = TAG) } + Unit + }.onFailure { + rollback() + Logger.error("updateAddressType failed", it, context = TAG) + }.also { + isChangingAddressType.set(false) + } + } + + suspend fun setMonitoring(addressType: AddressType, enabled: Boolean): Result = withContext(bgDispatcher) { + if (!isChangingAddressType.compareAndSet(false, true)) { + return@withContext Result.failure(AppError("Address type change already in progress")) + } + + val previousSettings = settingsStore.data.first() + val oldMonitored = previousSettings.addressTypesToMonitor.toList() + + if (!enabled) { + val validationError = validateDisableMonitoring(addressType, previousSettings, oldMonitored) + if (validationError != null) { + isChangingAddressType.set(false) + return@withContext Result.failure(validationError) + } + } + + val typeStr = addressType.toSettingsString() + val newMonitored = if (enabled) (oldMonitored + typeStr).distinct() else oldMonitored.filter { it != typeStr } + + suspend fun rollback() = settingsStore.update { it.copy(addressTypesToMonitor = oldMonitored) } + + runCatching { + settingsStore.update { it.copy(addressTypesToMonitor = newMonitored) } + if (enabled) { + lightningService.addAddressTypeToMonitor(addressType) + } else { + lightningService.removeAddressTypeFromMonitor(addressType) + } + sync().onFailure { Logger.warn("Sync after monitoring change failed", it, context = TAG) } + Unit + }.onFailure { + rollback() + Logger.error("setMonitoring failed", it, context = TAG) + }.also { + isChangingAddressType.set(false) + } + } + + private suspend fun validateDisableMonitoring( + addressType: AddressType, + settings: SettingsData, + monitoredTypes: List, + ): AppError? { + if (addressType == settings.selectedAddressType.toAddressType()) { + return AppError("Cannot disable monitoring: address type is currently selected") + } + if (isLastRequiredNativeWitnessWallet(addressType, monitoredTypes)) { + return AppError( + "Cannot disable monitoring: at least one Native SegWit or Taproot wallet required for Lightning" + ) + } + val balance = getBalanceForAddressType(addressType).getOrElse { + return AppError("Cannot disable monitoring: failed to verify balance") + } + if (balance > 0uL) { + return AppError("Cannot disable monitoring: address type has balance") + } + return null + } + + fun isChangingAddressType(): Boolean = isChangingAddressType.get() + + suspend fun pruneEmptyAddressTypesAfterRestore(): Result = withContext(bgDispatcher) { + if (isChangingAddressType.get()) return@withContext Result.success(Unit) + + val settings = settingsStore.data.first() + val selectedType = settings.selectedAddressType.toAddressType() ?: AddressType.P2WPKH + val monitored = settings.addressTypesToMonitor.toMutableList() + val nativeWitnessTypes = setOf(AddressType.P2WPKH, AddressType.P2TR) + + val toRemove = monitored.filter { typeStr -> + if (typeStr == settings.selectedAddressType) return@filter false + val type = typeStr.toAddressType() ?: return@filter false + val balance = getBalanceForAddressType(type).getOrNull() ?: return@filter false + if (balance != 0uL) return@filter false + val wouldLeaveNativeWitness = (selectedType in nativeWitnessTypes) || + monitored.any { it != typeStr && it.toAddressType() in nativeWitnessTypes } + wouldLeaveNativeWitness + } + + if (toRemove.isEmpty()) return@withContext Result.success(Unit) + + val newMonitored = monitored.filter { it !in toRemove } + settingsStore.update { it.copy(addressTypesToMonitor = newMonitored) } + for (typeStr in toRemove) { + val type = typeStr.toAddressType() ?: continue + runCatching { lightningService.removeAddressTypeFromMonitor(type) }.onFailure { + Logger.error("Failed to remove address type $typeStr from monitor", it, context = TAG) + } + } + sync().onFailure { Logger.warn("Sync after prune failed", it, context = TAG) } + Result.success(Unit) + } + + private fun isLastRequiredNativeWitnessWallet(addressType: AddressType, monitoredTypes: List): Boolean { + val nativeWitnessTypes = setOf(AddressType.P2WPKH, AddressType.P2TR) + if (addressType !in nativeWitnessTypes) return false + val monitored = monitoredTypes.mapNotNull { it.toAddressType() } + val remaining = monitored.filter { it != addressType && it in nativeWitnessTypes } + return remaining.isEmpty() + } + private suspend fun restartWithPreviousConfig(): Result = withContext(bgDispatcher) { Logger.debug("Stopping node for recovery attempt", context = TAG) @@ -803,12 +973,15 @@ class LightningRepo @Inject constructor( val transactionSpeed = speed ?: settingsStore.data.first().defaultTransactionSpeed val satsPerVByte = getFeeRateForSpeed(transactionSpeed, feeRates).getOrThrow() - // use passed utxos if specified, otherwise run auto coin select if enabled - val finalUtxosToSpend = utxosToSpend ?: determineUtxosToSpend(sats, satsPerVByte) + // transfer send-all: skip UTXO selection to avoid LDK buffer; else use passed or auto-selected + val utxosForSend = when { + isTransfer && isMaxAmount -> null + else -> utxosToSpend ?: determineUtxosToSpend(sats, satsPerVByte) + } - Logger.debug("UTXOs selected to spend: $finalUtxosToSpend", context = TAG) + Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) - val txId = lightningService.send(address, sats, satsPerVByte, finalUtxosToSpend, isMaxAmount) + val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( paymentId = txId, diff --git a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt deleted file mode 100644 index 36209cd1f..000000000 --- a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt +++ /dev/null @@ -1,137 +0,0 @@ -package to.bitkit.repositories - -import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.broadcastSweepTransaction -import com.synonym.bitkitcore.checkSweepableBalances -import com.synonym.bitkitcore.prepareSweepTransaction -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import to.bitkit.async.ServiceQueue -import to.bitkit.data.keychain.Keychain -import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env -import to.bitkit.models.toCoreNetwork -import to.bitkit.services.CoreService -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import to.bitkit.viewmodels.SweepResult -import to.bitkit.viewmodels.SweepTransactionPreview -import to.bitkit.viewmodels.SweepableBalances -import javax.inject.Inject -import javax.inject.Singleton -import com.synonym.bitkitcore.SweepResult as BitkitCoreSweepResult -import com.synonym.bitkitcore.SweepTransactionPreview as BitkitCoreSweepTransactionPreview -import com.synonym.bitkitcore.SweepableBalances as BitkitCoreSweepableBalances - -@Singleton -class SweepRepo @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val keychain: Keychain, - private val coreService: CoreService, -) { - suspend fun checkSweepableBalances(): Result = withContext(bgDispatcher) { - runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - Logger.debug("Checking sweepable balances...", context = TAG) - - val balances = ServiceQueue.CORE.background { - checkSweepableBalances( - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - ) - } - - balances.toSweepableBalances() - } - } - - suspend fun prepareSweepTransaction( - destinationAddress: String, - feeRateSatsPerVbyte: UInt, - ): Result = withContext(bgDispatcher) { - runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - Logger.debug("Preparing sweep transaction...", context = TAG) - - val preview = ServiceQueue.CORE.background { - prepareSweepTransaction( - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - destinationAddress = destinationAddress, - feeRateSatsPerVbyte = feeRateSatsPerVbyte, - ) - } - - preview.toSweepTransactionPreview() - } - } - - suspend fun broadcastSweepTransaction(psbt: String): Result = withContext(bgDispatcher) { - runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - Logger.debug("Broadcasting sweep transaction...", context = TAG) - - val result = ServiceQueue.CORE.background { - broadcastSweepTransaction( - psbt = psbt, - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - ) - } - - result.toSweepResult() - } - } - - suspend fun getFeeRates(): Result = coreService.blocktank.getFees() - - suspend fun hasSweepableFunds(): Result = checkSweepableBalances().map { balances -> - val hasFunds = balances.totalBalance > 0u - if (hasFunds) { - Logger.info("Found ${balances.totalBalance} sats to sweep", context = TAG) - } else { - Logger.debug("No sweepable funds found", context = TAG) - } - hasFunds - } - - companion object { - private const val TAG = "SweepRepo" - } -} - -private fun BitkitCoreSweepableBalances.toSweepableBalances() = SweepableBalances( - legacyBalance = legacyBalance, - legacyUtxosCount = legacyUtxosCount, - p2shBalance = p2shBalance, - p2shUtxosCount = p2shUtxosCount, - taprootBalance = taprootBalance, - taprootUtxosCount = taprootUtxosCount, -) - -private fun BitkitCoreSweepTransactionPreview.toSweepTransactionPreview() = SweepTransactionPreview( - psbt = psbt, - estimatedFee = estimatedFee, - amountAfterFees = amountAfterFees, - estimatedVsize = estimatedVsize, -) - -private fun BitkitCoreSweepResult.toSweepResult() = SweepResult( - txid = txid, - amountSwept = amountSwept, -) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3b5782db4..0e225bead 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -25,6 +25,7 @@ import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex +import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.toDerivationPath @@ -286,6 +287,12 @@ class WalletRepo @Inject constructor( if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } + settingsStore.update { + it.copy( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + ) + } setWalletExistsState() }.onFailure { Logger.error("createWallet error", it, context = TAG) @@ -299,6 +306,12 @@ class WalletRepo @Inject constructor( if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } + settingsStore.update { + it.copy( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = ALL_ADDRESS_TYPE_STRINGS, + ) + } setWalletExistsState() }.onFailure { Logger.error("restoreWallet error", it, context = TAG) @@ -331,10 +344,23 @@ class WalletRepo @Inject constructor( .onFailure { error -> Logger.error("Error generating new address", error, context = TAG) } } + suspend fun refreshReceiveAddressAfterTypeChange(): Result = withContext(bgDispatcher) { + runCatching { + cacheStore.update { it.resetBip21() } + _walletState.update { it.copy(onchainAddress = "", bolt11 = "", bip21 = "") } + newAddress() + updateBip21Invoice() + Unit + }.onFailure { + Logger.error("refreshReceiveAddressAfterTypeChange failed", it, context = TAG) + } + } + suspend fun getAddresses( startIndex: Int = 0, isChange: Boolean = false, count: Int = 20, + addressType: AddressType = AddressType.P2WPKH, ): Result> = withContext(bgDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) @@ -342,7 +368,7 @@ class WalletRepo @Inject constructor( val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - val baseDerivationPath = AddressType.P2WPKH.toDerivationPath( + val baseDerivationPath = addressType.toDerivationPath( index = 0, isChange = isChange, ).substringBeforeLast("/0") diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4f82a9ece..a3d876325 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -69,9 +69,11 @@ import org.lightningdevkit.ldknode.PaymentStatus import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.ext.create +import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -91,6 +93,7 @@ class CoreService @Inject constructor( private val lightningService: LightningService, private val httpClient: HttpClient, private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, ) { private var walletIndex: Int = 0 @@ -98,7 +101,8 @@ class CoreService @Inject constructor( ActivityService( coreService = this, cacheStore = cacheStore, - lightningService = lightningService + lightningService = lightningService, + settingsStore = settingsStore, ) } val blocktank: BlocktankService by lazy { @@ -205,6 +209,7 @@ class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, private val lightningService: LightningService, + private val settingsStore: SettingsStore, ) { suspend fun removeAll() { ServiceQueue.CORE.background { @@ -511,7 +516,13 @@ class ActivityService( }.getOrNull() private suspend fun findAddressInPreActivityMetadata(details: BitkitCoreTransactionDetails): String? { - for (output in details.outputs) { + val selectedType = settingsStore.data.first().selectedAddressType + val outputsByPriority = details.outputs.sortedBy { output -> + val address = output.scriptpubkeyAddress ?: return@sortedBy Int.MAX_VALUE + val typeStr = address.addressTypeFromAddress() ?: return@sortedBy Int.MAX_VALUE + if (typeStr == selectedType) 0 else 1 + } + for (output in outputsByPriority) { val address = output.scriptpubkeyAddress ?: continue val metadata = coreService.activity.getPreActivityMetadata( searchKey = address, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index e20658ca9..1b88261ea 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -48,6 +49,7 @@ import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult +import to.bitkit.models.toAddressType import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter @@ -61,6 +63,7 @@ import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import kotlin.io.path.Path import kotlin.time.Duration +import org.lightningdevkit.ldknode.AddressType as LdkAddressType typealias NodeEventHandler = suspend (Event) -> Unit @@ -142,11 +145,21 @@ class LightningService @Inject constructor( config: Config, channelMigration: ChannelDataMigration? = null, ): Node = ServiceQueue.LDK.background { + val settings = settingsStore.data.first() + val selectedType = settings.selectedAddressType.toAddressType()?.toLdkAddressType() + ?: LdkAddressType.NATIVE_SEGWIT + val monitoredTypes = settings.addressTypesToMonitor + .mapNotNull { it.toAddressType() } + .filter { it.toLdkAddressType() != selectedType } + .map { it.toLdkAddressType() } + val builder = Builder.fromConfig(config).apply { setCustomLogger(LdkLogWriter()) configureChainSource(customServerUrl) configureGossipSource(customRgsServerUrl) configureScorerSource() + setAddressType(selectedType) + setAddressTypesToMonitor(monitoredTypes) if (channelMigration != null) { setChannelDataMigration(channelMigration) @@ -911,6 +924,39 @@ class LightningService @Inject constructor( } } + suspend fun getBalanceForAddressType(addressType: AddressType): org.lightningdevkit.ldknode.AddressTypeBalance = + ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + n.getBalanceForAddressType(addressType.toLdkAddressType()) + } + + suspend fun setPrimaryAddressType(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + n.setPrimaryAddressTypeWithMnemonic(addressType.toLdkAddressType(), mnemonic, passphrase) + } + + suspend fun addAddressTypeToMonitor(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + n.addAddressTypeToMonitorWithMnemonic(addressType.toLdkAddressType(), mnemonic, passphrase) + } + + suspend fun removeAddressTypeFromMonitor(addressType: AddressType) = ServiceQueue.LDK.background { + val n = node ?: throw ServiceError.NodeNotSetup() + n.removeAddressTypeFromMonitor(addressType.toLdkAddressType()) + } + + private fun AddressType.toLdkAddressType(): LdkAddressType = when (this) { + AddressType.P2PKH -> LdkAddressType.LEGACY + AddressType.P2SH -> LdkAddressType.NESTED_SEGWIT + AddressType.P2WPKH -> LdkAddressType.NATIVE_SEGWIT + AddressType.P2TR -> LdkAddressType.TAPROOT + else -> LdkAddressType.NATIVE_SEGWIT + } + // region state val nodeId: String? get() = node?.nodeId() val balances: BalanceDetails? get() = node?.listBalances() diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 7456c8686..deb82123f 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -839,6 +839,12 @@ class MigrationService @Inject constructor( hasSeenTransferIntro = settings.transferIntroSeen ?: current.hasSeenTransferIntro, hasSeenSpendingIntro = settings.spendingIntroSeen ?: current.hasSeenSpendingIntro, hasSeenSavingsIntro = settings.savingsIntroSeen ?: current.hasSeenSavingsIntro, + selectedAddressType = settings.selectedAddressType ?: current.selectedAddressType, + addressTypesToMonitor = run { + val selected = settings.selectedAddressType ?: current.selectedAddressType + val monitored = settings.addressTypesToMonitor ?: current.addressTypesToMonitor + if (selected in monitored) monitored else (monitored + selected).distinct() + }, ) } } @@ -1970,6 +1976,8 @@ data class RNSettings( val transferIntroSeen: Boolean? = null, val spendingIntroSeen: Boolean? = null, val savingsIntroSeen: Boolean? = null, + val selectedAddressType: String? = null, + val addressTypesToMonitor: List? = null, ) @Serializable diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..65aa206d6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -134,15 +134,11 @@ import to.bitkit.ui.settings.LogsScreen import to.bitkit.ui.settings.OrderDetailScreen import to.bitkit.ui.settings.SecuritySettingsScreen import to.bitkit.ui.settings.SettingsScreen +import to.bitkit.ui.settings.advanced.AddressTypePreferenceScreen import to.bitkit.ui.settings.advanced.AddressViewerScreen import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen import to.bitkit.ui.settings.advanced.ElectrumConfigScreen import to.bitkit.ui.settings.advanced.RgsServerScreen -import to.bitkit.ui.settings.advanced.sweep.SweepConfirmScreen -import to.bitkit.ui.settings.advanced.sweep.SweepFeeCustomScreen -import to.bitkit.ui.settings.advanced.sweep.SweepFeeRateScreen -import to.bitkit.ui.settings.advanced.sweep.SweepSettingsScreen -import to.bitkit.ui.settings.advanced.sweep.SweepSuccessScreen import to.bitkit.ui.settings.appStatus.AppStatusScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings @@ -178,7 +174,6 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet -import to.bitkit.ui.sheets.SweepPromptSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.theme.TRANSITION_SHEET_MS import to.bitkit.ui.utils.AutoReadClipboardHandler @@ -195,7 +190,6 @@ import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.SettingsViewModel -import to.bitkit.viewmodels.SweepViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -338,10 +332,7 @@ fun ContentView( return } else if (restoreState is RestoreState.Completed) { WalletRestoreSuccessView( - onContinue = { - walletViewModel.onRestoreContinue() - appViewModel.checkForSweepableFunds() - }, + onContinue = { walletViewModel.onRestoreContinue() }, ) return } @@ -405,14 +396,6 @@ fun ContentView( is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) - Sheet.SweepPrompt -> SweepPromptSheet( - onSweep = { - appViewModel.hideSheet() - navController.navigate(Routes.SweepNav) - }, - onCancel = { appViewModel.hideSheet() }, - ) - is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { @@ -1026,37 +1009,12 @@ private fun NavGraphBuilder.advancedSettings(navController: NavHostController) { composableWithDefaultTransitions { RgsServerScreen(it.savedStateHandle, navController) } + composableWithDefaultTransitions { + AddressTypePreferenceScreen(navController) + } composableWithDefaultTransitions { AddressViewerScreen(navController) } - navigationWithDefaultTransitions( - startDestination = Routes.Sweep, - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } - val viewModel = hiltViewModel(parentEntry) - SweepSettingsScreen(navController, viewModel) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } - val viewModel = hiltViewModel(parentEntry) - SweepConfirmScreen(navController, viewModel) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } - val viewModel = hiltViewModel(parentEntry) - SweepFeeRateScreen(navController, viewModel) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SweepNav) } - val viewModel = hiltViewModel(parentEntry) - SweepFeeCustomScreen(navController, viewModel) - } - composableWithDefaultTransitions { - val route = it.toRoute() - SweepSuccessScreen(navController, amountSats = route.amountSats) - } - } composableWithDefaultTransitions { NodeInfoScreen(navController) } @@ -1749,25 +1707,10 @@ sealed interface Routes { data object RgsServer : Routes @Serializable - data object AddressViewer : Routes - - @Serializable - data object SweepNav : Routes - - @Serializable - data object Sweep : Routes - - @Serializable - data object SweepConfirm : Routes - - @Serializable - data object SweepFeeRate : Routes + data object AddressTypePreference : Routes @Serializable - data object SweepFeeCustom : Routes - - @Serializable - data class SweepSuccess(val amountSats: Long) : Routes + data object AddressViewer : Routes @Serializable data object AboutSettings : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 599569b21..f7700fc5e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -45,7 +45,6 @@ sealed interface Sheet { data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet - data object SweepPrompt : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet } diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt index 194057284..2b512440f 100644 --- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Switch @@ -12,9 +12,11 @@ import androidx.compose.material3.SwitchColors import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface @@ -26,10 +28,11 @@ fun SettingsSwitchRow( isChecked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + subtitle: String? = null, colors: SwitchColors = AppSwitchDefaults.colors, ) { Column( - modifier = modifier.height(52.dp) + modifier = modifier.heightIn(min = 52.dp), ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -39,7 +42,17 @@ fun SettingsSwitchRow( .clickableAlpha { onClick() } .padding(vertical = 16.dp) ) { - BodyM(text = title, color = Colors.White) + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + BodyM(text = title, color = Colors.White, overflow = TextOverflow.Ellipsis) + if (subtitle != null) { + BodyS(text = subtitle, color = Colors.White64) + } + } Switch( checked = isChecked, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index b2e806b47..3fd7172a8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -44,8 +44,8 @@ fun FundingScreen( onBackClick: () -> Unit = {}, ) { val balances = LocalBalances.current - val canTransfer = remember(balances.totalOnchainSats) { - balances.totalOnchainSats >= Defaults.recommendedBaseFee + val canTransfer = remember(balances.channelFundableBalance) { + balances.channelFundableBalance >= Defaults.recommendedBaseFee } var showNoFundsAlert by remember { mutableStateOf(false) } @@ -85,12 +85,12 @@ fun FundingScreen( onClick = onTransfer, modifier = Modifier.testTag("FundTransfer") ) - if (balances.totalOnchainSats == 0uL) { + if (balances.channelFundableBalance == 0uL) { Box( modifier = Modifier .matchParentSize() .clickable( - enabled = balances.totalOnchainSats == 0uL, + enabled = balances.channelFundableBalance == 0uL, interactionSource = null, indication = null, onClick = { showNoFundsAlert = true } diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 6e4467bb3..dfba44064 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -16,12 +16,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.Routes import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.navigateToHome import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar @@ -35,13 +37,18 @@ fun AdvancedSettingsScreen( viewModel: AdvancedSettingsViewModel = hiltViewModel(), ) { var showResetSuggestionsDialog by remember { mutableStateOf(false) } + val selectedAddressTypeName by viewModel.selectedAddressTypeName.collectAsStateWithLifecycle() Content( showResetSuggestionsDialog = showResetSuggestionsDialog, + selectedAddressTypeName = selectedAddressTypeName, onBack = { navController.popBackStack() }, onCoinSelectionClick = { navController.navigate(Routes.CoinSelectPreference) }, + onAddressTypePreferenceClick = { + navController.navigate(Routes.AddressTypePreference) + }, onLightningConnectionsClick = { navController.navigate(Routes.LightningConnections) }, @@ -57,9 +64,6 @@ fun AdvancedSettingsScreen( onAddressViewerClick = { navController.navigate(Routes.AddressViewer) }, - onSweepFundsClick = { - navController.navigate(Routes.SweepNav) - }, onSuggestionsResetClick = { showResetSuggestionsDialog = true }, onResetSuggestionsDialogConfirm = { viewModel.resetSuggestions() @@ -73,14 +77,15 @@ fun AdvancedSettingsScreen( @Composable private fun Content( showResetSuggestionsDialog: Boolean, + selectedAddressTypeName: String = "", onBack: () -> Unit = {}, onCoinSelectionClick: () -> Unit = {}, + onAddressTypePreferenceClick: () -> Unit = {}, onLightningConnectionsClick: () -> Unit = {}, onLightningNodeClick: () -> Unit = {}, onElectrumServerClick: () -> Unit = {}, onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, - onSweepFundsClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, onResetSuggestionsDialogCancel: () -> Unit = {}, @@ -101,6 +106,17 @@ private fun Content( // Payments Section SectionHeader(title = stringResource(R.string.settings__adv__section_payments)) + SettingsButtonRow( + title = stringResource(R.string.settings__addr_type__title), + value = if (selectedAddressTypeName.isNotEmpty()) { + SettingsButtonValue.StringValue(selectedAddressTypeName) + } else { + SettingsButtonValue.None + }, + onClick = onAddressTypePreferenceClick, + modifier = Modifier.testTag("AddressTypePreference"), + ) + SettingsButtonRow( title = stringResource(R.string.settings__adv__coin_selection), onClick = onCoinSelectionClick, @@ -143,12 +159,6 @@ private fun Content( modifier = Modifier.testTag("AddressViewer"), ) - SettingsButtonRow( - title = stringResource(R.string.sweep__nav_title), - onClick = onSweepFundsClick, - modifier = Modifier.testTag("SweepFunds"), - ) - SettingsButtonRow( title = stringResource(R.string.settings__adv__suggestions_reset), onClick = onSuggestionsResetClick, @@ -177,6 +187,7 @@ private fun Preview() { AppThemeSurface { Content( showResetSuggestionsDialog = false, + selectedAddressTypeName = "Taproot", ) } } @@ -187,6 +198,7 @@ private fun PreviewDialog() { AppThemeSurface { Content( showResetSuggestionsDialog = true, + selectedAddressTypeName = "Taproot", ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index 097b0f4cc..3fe3a0ede 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -3,8 +3,13 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore +import to.bitkit.models.addressTypeInfo +import to.bitkit.models.toAddressType import javax.inject.Inject @HiltViewModel @@ -12,6 +17,10 @@ class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, ) : ViewModel() { + val selectedAddressTypeName = settingsStore.data + .map { it.selectedAddressType.toAddressType()?.addressTypeInfo()?.shortName ?: "" } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + fun resetSuggestions() { viewModelScope.launch { settingsStore.update { it.copy(dismissedSuggestions = emptyList()) } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt new file mode 100644 index 000000000..6a01aea1f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceScreen.kt @@ -0,0 +1,136 @@ +package to.bitkit.ui.settings.advanced + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.synonym.bitkitcore.AddressType +import to.bitkit.R +import to.bitkit.models.addressTypeInfo +import to.bitkit.models.toSettingsString +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsButtonValue +import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface + +private val ADDRESS_TYPES = listOf( + AddressType.P2PKH, + AddressType.P2SH, + AddressType.P2WPKH, + AddressType.P2TR, +) + +private fun AddressType.toAddressTypeE2eId(): String = when (this) { + AddressType.P2PKH -> "p2pkh" + AddressType.P2SH -> "p2sh-p2wpkh" + AddressType.P2WPKH -> "p2wpkh" + AddressType.P2TR -> "p2tr" + else -> "p2wpkh" +} + +@Composable +fun AddressTypePreferenceScreen( + navController: NavController, + viewModel: AddressTypePreferenceViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onSelectAddressType = viewModel::updateAddressType, + onSetMonitoring = viewModel::setMonitoring, + ) +} + +@Composable +private fun Content( + uiState: AddressTypePreferenceUiState, + onBack: () -> Unit = {}, + onSelectAddressType: (AddressType) -> Unit = {}, + onSetMonitoring: (AddressType, Boolean) -> Unit = { _, _ -> }, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.settings__addr_type__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .testTag("AddressTypePreference"), + ) { + SectionHeader(title = stringResource(R.string.settings__addr_type__primary)) + + ADDRESS_TYPES.forEach { type -> + val info = type.addressTypeInfo() + SettingsButtonRow( + title = "${info.shortName} ${info.example}", + subtitle = info.description, + value = SettingsButtonValue.BooleanValue(uiState.selectedAddressType == type), + onClick = { onSelectAddressType(type) }, + modifier = Modifier.testTag(type.toAddressTypeE2eId()), + ) + } + + if (uiState.showMonitoredTypes) { + SectionHeader(title = stringResource(R.string.settings__addr_type__monitoring)) + + ADDRESS_TYPES.forEach { type -> + val info = type.addressTypeInfo() + val isMonitored = type.toSettingsString() in uiState.monitoredTypes + val isSelectedType = uiState.selectedAddressType == type + SettingsSwitchRow( + title = "${info.shortName} ${info.shortExample}", + subtitle = if (isSelectedType) { + stringResource(R.string.settings__adv__addr_type_currently_selected) + } else { + null + }, + isChecked = isMonitored, + onClick = { if (!isSelectedType) onSetMonitoring(type, !isMonitored) }, + modifier = Modifier + .alpha(if (isSelectedType) 0.5f else 1f) + .testTag("MonitorToggle-${type.toAddressTypeE2eId()}"), + ) + } + } + + VerticalSpacer(16.dp) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = AddressTypePreferenceUiState( + selectedAddressType = AddressType.P2WPKH, + monitoredTypes = setOf("nativeSegwit"), + showMonitoredTypes = true, + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt new file mode 100644 index 000000000..80bad6119 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt @@ -0,0 +1,152 @@ +package to.bitkit.ui.settings.advanced + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.AddressType +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsStore +import to.bitkit.di.BgDispatcher +import to.bitkit.models.Toast +import to.bitkit.models.toAddressType +import to.bitkit.models.toSettingsString +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +@HiltViewModel +class AddressTypePreferenceViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, + private val lightningRepo: LightningRepo, + private val walletRepo: WalletRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(AddressTypePreferenceUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadState() + } + + private fun loadState() { + viewModelScope.launch(bgDispatcher) { + settingsStore.data.first().let { settings -> + val selected = settings.selectedAddressType.toAddressType() ?: AddressType.P2WPKH + val monitored = settings.addressTypesToMonitor.toSet() + _uiState.update { + it.copy( + selectedAddressType = selected, + monitoredTypes = monitored, + showMonitoredTypes = settings.isDevModeEnabled, + ) + } + } + } + } + + fun updateAddressType(addressType: AddressType) { + if (_uiState.value.isLoading) return + if (_uiState.value.selectedAddressType == addressType) return + + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isLoading = true) } + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.settings__addr_type__applying), + autoHide = false, + testTag = "AddressTypeApplyingToast", + ) + + val currentMonitored = _uiState.value.monitoredTypes.toMutableSet() + currentMonitored.add(addressType.toSettingsString()) + val result = lightningRepo.updateAddressType( + selectedType = addressType.toSettingsString(), + monitoredTypes = currentMonitored.toList(), + ).onSuccess { + walletRepo.refreshReceiveAddressAfterTypeChange() + } + + _uiState.update { it.copy(isLoading = false) } + loadState() + + if (result.isSuccess) { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.settings__addr_type__settings_updated), + testTag = "AddressTypeSettingsUpdatedToast", + ) + } else { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.common__error), + description = result.exceptionOrNull()?.message, + ) + } + } + } + + fun setMonitoring(addressType: AddressType, enabled: Boolean) { + if (_uiState.value.isLoading) return + + val isMonitored = addressType.toSettingsString() in _uiState.value.monitoredTypes + if (isMonitored == enabled) return + + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isLoading = true) } + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.settings__addr_type__applying), + autoHide = false, + ) + + val repoResult = lightningRepo.setMonitoring(addressType, enabled) + + _uiState.update { it.copy(isLoading = false) } + loadState() + + if (repoResult.isSuccess) { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.settings__addr_type__settings_updated), + ) + } else { + val ex = repoResult.exceptionOrNull()?.message + val msg = when { + ex?.contains("has balance") == true -> + context.getString(R.string.settings__addr_type__disabled_has_balance) + ex?.contains("verify") == true -> + context.getString(R.string.settings__addr_type__disabled_verify_failed) + ex?.contains("Native SegWit or Taproot") == true -> + context.getString(R.string.settings__addr_type__disabled_native_required) + ex?.contains("currently selected") == true -> + context.getString(R.string.settings__addr_type__disabled_currently_selected) + else -> ex + } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.common__error), + description = msg, + ) + } + } + } +} + +data class AddressTypePreferenceUiState( + val selectedAddressType: AddressType = AddressType.P2WPKH, + val monitoredTypes: Set = setOf("nativeSegwit"), + val showMonitoredTypes: Boolean = false, + val isLoading: Boolean = false, +) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt index 09353a86c..b4794a5c1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt @@ -27,10 +27,12 @@ import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import com.synonym.bitkitcore.AddressType import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.AddressModel import to.bitkit.models.Toast +import to.bitkit.models.addressTypeInfo import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyS @@ -68,6 +70,7 @@ fun AddressViewerScreen( onSearchTextChanged = viewModel::updateSearchText, onAddressSelected = { address -> viewModel.selectAddress(address) }, onSwitchAddressType = viewModel::switchAddressType, + onSelectAddressType = viewModel::selectAddressType, onClickOpenBlockExplorer = { address -> val url = getBlockExplorerUrl(address, BlockExplorerType.ADDRESS) val intent = Intent(Intent.ACTION_VIEW, url.toUri()) @@ -86,6 +89,13 @@ fun AddressViewerScreen( ) } +private val ADDRESS_TYPES = listOf( + AddressType.P2PKH, + AddressType.P2SH, + AddressType.P2WPKH, + AddressType.P2TR, +) + @Composable private fun AddressViewerContent( uiState: UiState, @@ -93,6 +103,7 @@ private fun AddressViewerContent( onSearchTextChanged: (String) -> Unit = {}, onAddressSelected: (AddressModel) -> Unit = {}, onSwitchAddressType: (Boolean) -> Unit = {}, + onSelectAddressType: (AddressType) -> Unit = {}, onClickOpenBlockExplorer: (String) -> Unit = {}, onClickCheckBalances: () -> Unit = {}, onGenerateMoreAddresses: () -> Unit = {}, @@ -157,6 +168,23 @@ private fun AddressViewerContent( ) VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + ADDRESS_TYPES.forEach { type -> + val info = type.addressTypeInfo() + PrimaryButton( + text = info.shortName, + size = ButtonSize.Small, + onClick = { onSelectAddressType(type) }, + color = if (uiState.selectedAddressType == type) Colors.Brand else Colors.White16, + modifier = Modifier.weight(1f) + ) + } + } + VerticalSpacer(8.dp) + Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() @@ -299,6 +327,7 @@ private fun Preview() { AppThemeSurface { AddressViewerContent( uiState = UiState( + selectedAddressType = AddressType.P2WPKH, addresses = listOf( AddressModel( address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt index 657821d71..00d7339bd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.settings.advanced import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.AddressType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async @@ -11,11 +12,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.models.AddressModel +import to.bitkit.models.toAddressType import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import javax.inject.Inject @@ -23,6 +27,7 @@ import javax.inject.Inject @HiltViewModel class AddressViewerViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, ) : ViewModel() { @@ -31,7 +36,11 @@ class AddressViewerViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - loadAddresses() + viewModelScope.launch(bgDispatcher) { + val selected = settingsStore.data.first().selectedAddressType.toAddressType() ?: AddressType.P2WPKH + _uiState.update { it.copy(selectedAddressType = selected) } + loadAddresses() + } } fun loadAddresses() { @@ -43,6 +52,7 @@ class AddressViewerViewModel @Inject constructor( val addresses = walletRepo.getAddresses( isChange = !_uiState.value.showReceiveAddresses, + addressType = _uiState.value.selectedAddressType, ).getOrThrow() _uiState.update { currentState -> @@ -71,6 +81,7 @@ class AddressViewerViewModel @Inject constructor( val newAddresses = walletRepo.getAddresses( startIndex = nextStartIndex, isChange = !currentState.showReceiveAddresses, + addressType = currentState.selectedAddressType, ).getOrThrow() _uiState.update { currentState -> @@ -107,8 +118,10 @@ class AddressViewerViewModel @Inject constructor( _uiState.update { it.copy(showReceiveAddresses = isReceiving, isLoading = true) } runCatching { - val addresses = walletRepo.getAddresses(isChange = !isReceiving) - .getOrThrow() + val addresses = walletRepo.getAddresses( + isChange = !isReceiving, + addressType = _uiState.value.selectedAddressType, + ).getOrThrow() _uiState.update { currentState -> currentState.copy( @@ -129,6 +142,32 @@ class AddressViewerViewModel @Inject constructor( fun selectAddress(address: AddressModel) = _uiState.update { it.copy(selectedAddress = address) } + fun selectAddressType(addressType: AddressType) { + if (_uiState.value.selectedAddressType == addressType) return + + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(selectedAddressType = addressType, isLoading = true) } + + runCatching { + val addresses = walletRepo.getAddresses( + isChange = !_uiState.value.showReceiveAddresses, + addressType = addressType, + ).getOrThrow() + + _uiState.update { currentState -> + currentState.copy( + addresses = addresses, + selectedAddress = addresses.firstOrNull(), + balances = emptyMap(), + ) + } + loadBalancesForAddresses(addresses) + } + + _uiState.update { it.copy(isLoading = false) } + } + } + private fun loadBalancesForAddresses(newAddresses: List) { if (_uiState.value.isLoadingBalances) return @@ -177,4 +216,5 @@ data class UiState( val isLoading: Boolean = false, val isLoadingBalances: Boolean = false, val showReceiveAddresses: Boolean = true, + val selectedAddressType: AddressType = AddressType.P2WPKH, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt deleted file mode 100644 index 7b497d58f..000000000 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepConfirmScreen.kt +++ /dev/null @@ -1,300 +0,0 @@ -package to.bitkit.ui.settings.advanced.sweep - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import kotlinx.coroutines.launch -import to.bitkit.R -import to.bitkit.models.FeeRate -import to.bitkit.models.TransactionSpeed -import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.Routes -import to.bitkit.ui.components.BalanceHeaderView -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodySSB -import to.bitkit.ui.components.Caption -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.SwipeToConfirm -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.modifiers.clickableAlpha -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.SweepState -import to.bitkit.viewmodels.SweepUiState -import to.bitkit.viewmodels.SweepViewModel - -@Composable -fun SweepConfirmScreen( - navController: NavController, - viewModel: SweepViewModel, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - viewModel.loadFeeEstimates() - } - - LaunchedEffect(uiState.selectedFeeRate) { - if (uiState.selectedFeeRate != null && uiState.sweepState != SweepState.Broadcasting) { - viewModel.prepareSweep() - } - } - - LaunchedEffect(uiState.sweepState) { - if (uiState.sweepState is SweepState.Success) { - val amountSats = uiState.sweepResult?.amountSwept?.toLong() ?: 0L - navController.navigate(Routes.SweepSuccess(amountSats = amountSats)) { - popUpTo(Routes.Sweep) { inclusive = true } - } - } - } - - Content( - uiState = uiState, - onBack = { navController.popBackStack() }, - onSelectFeeRate = { navController.navigate(Routes.SweepFeeRate) }, - onSwipeComplete = { - scope.launch { - viewModel.broadcastSweep() - } - }, - ) -} - -@Composable -private fun Content( - uiState: SweepUiState, - onBack: () -> Unit = {}, - onSelectFeeRate: () -> Unit = {}, - onSwipeComplete: () -> Unit = {}, -) { - val isPreparing = uiState.sweepState == SweepState.Preparing - val isReady = uiState.sweepState == SweepState.Ready - - val displayAmount = if (isReady && uiState.transactionPreview != null) { - uiState.transactionPreview.amountAfterFees.toLong() - } else { - (uiState.sweepableBalances?.totalBalance ?: 0u).toLong() - } - - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.sweep__confirm_nav_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - VerticalSpacer(16.dp) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - BalanceHeaderView( - sats = displayAmount, - modifier = Modifier.alpha(if (isPreparing) 0.5f else 1f) - ) - - VerticalSpacer(24.dp) - - HorizontalDivider(color = Colors.White08) - - VerticalSpacer(24.dp) - - Caption( - text = stringResource(R.string.sweep__confirm_to_address), - color = Colors.White64, - ) - - VerticalSpacer(8.dp) - - BodySSB( - text = uiState.destinationAddress?.ifEmpty { "..." } ?: "...", - modifier = Modifier.alpha(if (uiState.destinationAddress == null) 0.5f else 1f) - ) - - VerticalSpacer(24.dp) - - HorizontalDivider(color = Colors.White08) - - VerticalSpacer(24.dp) - - val feeRate = FeeRate.fromSpeed(uiState.selectedSpeed) - val isLoading = isPreparing || uiState.sweepState == SweepState.Broadcasting - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .then( - if (!isLoading) { - Modifier.clickableAlpha(onClick = onSelectFeeRate) - } else { - Modifier - } - ) - ) { - Column { - Caption( - text = stringResource(R.string.wallet__send_fee_and_speed), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(feeRate.icon), - contentDescription = null, - tint = feeRate.color, - modifier = Modifier.size(16.dp) - ) - BodySSB( - text = if (uiState.estimatedFee > 0u && !isPreparing) { - " ${stringResource(feeRate.title)} (${ - stringResource( - R.string.sweep__balance_format, - uiState.estimatedFee.toLong().formatToModernDisplay(), - ) - })" - } else { - " ${stringResource(feeRate.title)}" - }, - ) - } - } - - Column(horizontalAlignment = Alignment.End) { - Caption( - text = stringResource(R.string.wallet__send_confirming_in), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_clock), - contentDescription = null, - tint = Colors.Brand, - modifier = Modifier.size(16.dp) - ) - BodySSB(text = " ${stringResource(feeRate.description)}") - } - } - } - - VerticalSpacer(24.dp) - - HorizontalDivider(color = Colors.White08) - - if (uiState.errorMessage != null) { - VerticalSpacer(16.dp) - BodyM( - text = uiState.errorMessage, - color = Colors.Red, - ) - } - - FillHeight() - - BottomActions( - uiState = uiState, - onSwipeComplete = onSwipeComplete, - ) - - VerticalSpacer(16.dp) - } - } -} - -@Composable -private fun BottomActions( - uiState: SweepUiState, - onSwipeComplete: () -> Unit, -) { - when (uiState.sweepState) { - SweepState.Idle, SweepState.Ready -> { - if (uiState.destinationAddress != null && uiState.transactionPreview != null) { - SwipeToConfirm( - text = stringResource(R.string.sweep__confirm_swipe), - onConfirm = onSwipeComplete, - modifier = Modifier.fillMaxWidth() - ) - } - } - SweepState.Preparing -> LoadingIndicator(stringResource(R.string.sweep__confirm_preparing)) - SweepState.Broadcasting -> LoadingIndicator(stringResource(R.string.sweep__confirm_broadcasting)) - is SweepState.Error -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - BodyM( - text = uiState.sweepState.message, - color = Colors.Red, - ) - VerticalSpacer(16.dp) - SwipeToConfirm( - text = stringResource(R.string.sweep__confirm_retry), - onConfirm = onSwipeComplete, - modifier = Modifier.fillMaxWidth() - ) - } - } - is SweepState.Success -> Unit - } -} - -@Composable -private fun LoadingIndicator(text: String) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = Colors.White32, - strokeWidth = 3.dp, - ) - VerticalSpacer(32.dp) - Caption( - text = text, - color = Colors.White64, - ) - } -} - -@Preview -@Composable -private fun Preview() { - AppThemeSurface { - Content( - uiState = SweepUiState( - destinationAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - selectedSpeed = TransactionSpeed.Medium, - ), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt deleted file mode 100644 index 059596738..000000000 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeCustomScreen.kt +++ /dev/null @@ -1,145 +0,0 @@ -package to.bitkit.ui.settings.advanced.sweep - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import to.bitkit.R -import to.bitkit.ext.toLongOrDefault -import to.bitkit.models.BITCOIN_SYMBOL -import to.bitkit.models.TransactionSpeed -import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.KEY_DELETE -import to.bitkit.ui.components.LargeRow -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadType -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SectionHeader -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.SweepViewModel - -@Composable -fun SweepFeeCustomScreen( - navController: NavController, - viewModel: SweepViewModel, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var input by remember { mutableStateOf("") } - - val estimatedVsize = uiState.transactionPreview?.estimatedVsize ?: 0u - val feeRate = input.toLongOrDefault(0) - val totalFee = feeRate * estimatedVsize.toLong() - val totalFeeText = if (totalFee > 0) { - stringResource( - R.string.sweep__balance_format, - totalFee.formatToModernDisplay(), - ) + " " + stringResource(R.string.sweep__custom_fee_total) - } else { - "" - } - - Content( - input = input, - totalFeeText = totalFeeText, - onKeyPress = { key -> - when (key) { - KEY_DELETE -> input = input.dropLast(1) - else -> { - if (input.length < 6) { - input += key - } - } - } - }, - onBack = { navController.popBackStack() }, - onContinue = { - val rate = input.toUIntOrNull() ?: 1u - viewModel.setFeeRate(TransactionSpeed.Custom(rate)) - navController.popBackStack() - }, - ) -} - -@Composable -private fun Content( - input: String, - totalFeeText: String, - onKeyPress: (String) -> Unit = {}, - onBack: () -> Unit = {}, - onContinue: () -> Unit = {}, -) { - val isValid = input.toLongOrDefault(0) >= 1L - - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.sweep__custom_fee_nav_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - ) { - SectionHeader(title = stringResource(R.string.common__sat_vbyte)) - - LargeRow( - prefix = null, - text = input.ifEmpty { "0" }, - symbol = BITCOIN_SYMBOL, - showSymbol = true, - ) - - if (isValid && totalFeeText.isNotEmpty()) { - VerticalSpacer(28.dp) - BodyM(totalFeeText, color = Colors.White64) - } - - FillHeight() - - NumberPad( - onPress = onKeyPress, - type = NumberPadType.SIMPLE, - modifier = Modifier.height(350.dp) - ) - - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinue, - enabled = isValid, - ) - - VerticalSpacer(16.dp) - } - } -} - -@Preview -@Composable -private fun Preview() { - AppThemeSurface { - Content( - input = "5", - totalFeeText = "₿ 256 for this transaction", - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt deleted file mode 100644 index 59c3ee9b4..000000000 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepFeeRateScreen.kt +++ /dev/null @@ -1,228 +0,0 @@ -package to.bitkit.ui.settings.advanced.sweep - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import to.bitkit.R -import to.bitkit.env.Defaults -import to.bitkit.models.FeeRate -import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.TransactionSpeed -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.Routes -import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.BodySSB -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.components.MoneyMSB -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SectionHeader -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.modifiers.clickableAlpha -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.SweepUiState -import to.bitkit.viewmodels.SweepViewModel - -@Composable -fun SweepFeeRateScreen( - navController: NavController, - viewModel: SweepViewModel, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - Content( - uiState = uiState, - onBack = { navController.popBackStack() }, - onSelectSpeed = { speed -> - viewModel.setFeeRate(speed) - navController.popBackStack() - }, - onCustom = { navController.navigate(Routes.SweepFeeCustom) }, - ) -} - -@Composable -private fun Content( - uiState: SweepUiState, - onBack: () -> Unit = {}, - onSelectSpeed: (TransactionSpeed) -> Unit = {}, - onCustom: () -> Unit = {}, -) { - val feeRates = uiState.feeRates - val estimatedVsize = uiState.transactionPreview?.estimatedVsize ?: 0u - val totalBalance = uiState.sweepableBalances?.totalBalance ?: 0u - - fun getFee(speed: TransactionSpeed): Long { - val feeRate: UInt = when (speed) { - is TransactionSpeed.Custom -> speed.satsPerVByte - else -> feeRates?.let { speed.getFeeRate(it) } ?: 0u - } - return (feeRate.toULong() * estimatedVsize).toLong() - } - - fun isDisabled(speed: TransactionSpeed): Boolean { - val fee = getFee(speed).toULong() - return fee + Defaults.dustLimit > totalBalance - } - - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.sweep__fee_nav_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - Column(modifier = Modifier.fillMaxSize()) { - SectionHeader( - title = stringResource(R.string.wallet__send_fee_and_speed), - modifier = Modifier.padding(horizontal = 16.dp) - ) - - if (feeRates == null) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = Colors.White32, - ) - } - return@ScreenColumn - } - - FeeItem( - feeRate = FeeRate.FAST, - sats = getFee(TransactionSpeed.Fast), - isSelected = uiState.selectedSpeed is TransactionSpeed.Fast, - isDisabled = isDisabled(TransactionSpeed.Fast), - onClick = { onSelectSpeed(TransactionSpeed.Fast) }, - ) - - FeeItem( - feeRate = FeeRate.NORMAL, - sats = getFee(TransactionSpeed.Medium), - isSelected = uiState.selectedSpeed is TransactionSpeed.Medium, - isDisabled = isDisabled(TransactionSpeed.Medium), - onClick = { onSelectSpeed(TransactionSpeed.Medium) }, - ) - - FeeItem( - feeRate = FeeRate.SLOW, - sats = getFee(TransactionSpeed.Slow), - isSelected = uiState.selectedSpeed is TransactionSpeed.Slow, - isDisabled = isDisabled(TransactionSpeed.Slow), - onClick = { onSelectSpeed(TransactionSpeed.Slow) }, - ) - - val customRate = (uiState.selectedSpeed as? TransactionSpeed.Custom)?.satsPerVByte ?: 0u - FeeItem( - feeRate = FeeRate.CUSTOM, - sats = if (customRate > 0u) getFee(TransactionSpeed.Custom(customRate)) else 0L, - isSelected = uiState.selectedSpeed is TransactionSpeed.Custom, - isDisabled = false, - onClick = onCustom, - ) - - FillHeight(min = 16.dp) - - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onBack, - modifier = Modifier.padding(horizontal = 16.dp) - ) - - VerticalSpacer(16.dp) - } - } -} - -@Composable -private fun FeeItem( - feeRate: FeeRate, - sats: Long, - onClick: () -> Unit, - modifier: Modifier = Modifier, - isSelected: Boolean = false, - isDisabled: Boolean = false, - unit: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, -) { - val color = if (isDisabled) Colors.Gray3 else MaterialTheme.colorScheme.primary - val accent = if (isDisabled) Colors.Gray3 else MaterialTheme.colorScheme.secondary - Column( - modifier = modifier - .clickableAlpha(onClick = onClick) - .then( - if (isSelected) Modifier.background(Colors.White06) else Modifier - ), - ) { - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp) - .height(90.dp) - ) { - Icon( - painter = painterResource(feeRate.icon), - contentDescription = null, - tint = when { - isDisabled -> Colors.Gray3 - else -> feeRate.color - }, - modifier = Modifier.size(32.dp), - ) - HorizontalSpacer(16.dp) - Column { - BodyMSB(stringResource(feeRate.title), color = color) - BodySSB(stringResource(feeRate.description), color = accent) - } - FillWidth() - if (sats != 0L) { - Column( - horizontalAlignment = Alignment.End, - ) { - MoneyMSB(sats, color = color, accent = accent) - MoneySSB(sats, unit = unit.not(), color = accent, accent = accent, showSymbol = true) - } - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - Content( - uiState = SweepUiState( - selectedSpeed = TransactionSpeed.Medium, - ), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt deleted file mode 100644 index bc7cf938e..000000000 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSettingsScreen.kt +++ /dev/null @@ -1,380 +0,0 @@ -package to.bitkit.ui.settings.advanced.sweep - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import to.bitkit.R -import to.bitkit.ui.Routes -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodySSB -import to.bitkit.ui.components.Caption -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.Title -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.rememberMoneyText -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppTextStyles -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.CheckState -import to.bitkit.viewmodels.SweepUiState -import to.bitkit.viewmodels.SweepViewModel -import to.bitkit.viewmodels.SweepableBalances - -@Composable -fun SweepSettingsScreen( - navController: NavController, - viewModel: SweepViewModel, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.reset() - viewModel.checkBalance() - } - - Content( - uiState = uiState, - onBack = { navController.popBackStack() }, - onSweepToWallet = { navController.navigate(Routes.SweepConfirm) }, - onRetry = { viewModel.checkBalance() }, - ) -} - -@Composable -private fun Content( - uiState: SweepUiState, - onBack: () -> Unit = {}, - onSweepToWallet: () -> Unit = {}, - onRetry: () -> Unit = {}, -) { - val title = when (uiState.checkState) { - is CheckState.Found -> stringResource(R.string.sweep__found_title) - is CheckState.NoFunds -> stringResource(R.string.sweep__no_funds_title) - else -> stringResource(R.string.sweep__nav_title) - } - - ScreenColumn { - AppTopBar( - titleText = title, - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - VerticalSpacer(30.dp) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - when (uiState.checkState) { - CheckState.Idle, CheckState.Checking -> LoadingView() - is CheckState.Found -> FoundFundsView( - balances = uiState.sweepableBalances ?: SweepableBalances(), - onSweepToWallet = onSweepToWallet, - ) - CheckState.NoFunds -> NoFundsView(onBack = onBack) - is CheckState.Error -> ErrorView( - message = uiState.checkState.message, - onRetry = onRetry, - ) - } - } - } -} - -@Composable -private fun LoadingView() { - Column( - modifier = Modifier.fillMaxSize() - ) { - BodyM( - text = stringResource(R.string.sweep__checking_description), - color = Colors.White64, - ) - - Spacer(modifier = Modifier.weight(1f)) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Image( - painter = painterResource(id = R.drawable.magnifying_glass), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.size(311.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = Colors.White32, - strokeWidth = 3.dp, - ) - - VerticalSpacer(16.dp) - - Caption( - text = stringResource(R.string.sweep__checking_loading), - color = Colors.White64, - ) - } - - VerticalSpacer(32.dp) - } -} - -@Composable -private fun FoundFundsView( - balances: SweepableBalances, - onSweepToWallet: () -> Unit, -) { - Column( - modifier = Modifier.fillMaxSize() - ) { - BodyM( - text = stringResource(R.string.sweep__found_description), - color = Colors.White64, - ) - - VerticalSpacer(24.dp) - - Caption( - text = stringResource(R.string.sweep__found_label), - color = Colors.White64, - ) - - VerticalSpacer(16.dp) - - if (balances.legacyBalance > 0u) { - FundRow( - title = stringResource(R.string.sweep__legacy_title), - utxoCount = balances.legacyUtxosCount, - balance = balances.legacyBalance, - ) - } - - if (balances.p2shBalance > 0u) { - FundRow( - title = stringResource(R.string.sweep__segwit_title), - utxoCount = balances.p2shUtxosCount, - balance = balances.p2shBalance, - ) - } - - if (balances.taprootBalance > 0u) { - FundRow( - title = stringResource(R.string.sweep__taproot_title), - utxoCount = balances.taprootUtxosCount, - balance = balances.taprootBalance, - ) - } - - VerticalSpacer(16.dp) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Title(text = stringResource(R.string.sweep__total)) - rememberMoneyText(sats = balances.totalBalance.toLong(), showSymbol = true)?.let { - Text(text = it.withAccent(accentColor = Colors.White), style = AppTextStyles.Title) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton( - text = stringResource(R.string.sweep__to_wallet), - onClick = onSweepToWallet, - modifier = Modifier - .fillMaxWidth() - .testTag("SweepToWalletButton") - ) - - VerticalSpacer(16.dp) - } -} - -@Composable -private fun FundRow( - title: String, - utxoCount: UInt, - balance: ULong, -) { - val utxoLabel = if (utxoCount == 1u) { - stringResource(R.string.sweep__utxo_format, title, utxoCount.toInt()) - } else { - stringResource(R.string.sweep__utxos_format, title, utxoCount.toInt()) - } - Column { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - BodySSB(text = utxoLabel) - MoneySSB(sats = balance.toLong(), showSymbol = true) - } - HorizontalDivider(color = Colors.White08) - } -} - -@Composable -private fun NoFundsView(onBack: () -> Unit) { - Column( - modifier = Modifier.fillMaxSize() - ) { - BodyM( - text = stringResource(R.string.sweep__no_funds_description), - color = Colors.White64, - ) - - Spacer(modifier = Modifier.weight(1f)) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Image( - painter = painterResource(id = R.drawable.check), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.size(311.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton( - text = stringResource(R.string.common__ok), - onClick = onBack, - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(16.dp) - } -} - -@Composable -private fun ErrorView( - message: String, - onRetry: () -> Unit, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - Spacer(modifier = Modifier.weight(1f)) - - Icon( - painter = painterResource(id = R.drawable.ic_warning), - contentDescription = null, - tint = Colors.Red, - modifier = Modifier.size(64.dp) - ) - - VerticalSpacer(24.dp) - - BodySSB(text = stringResource(R.string.sweep__error_title)) - - VerticalSpacer(8.dp) - - BodyM( - text = message, - color = Colors.White64, - ) - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton( - text = stringResource(R.string.common__retry), - onClick = onRetry, - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(16.dp) - } -} - -@Preview -@Composable -private fun PreviewLoading() { - AppThemeSurface { - Content( - uiState = SweepUiState(checkState = CheckState.Checking), - ) - } -} - -@Preview -@Composable -private fun PreviewFound() { - AppThemeSurface { - Content( - uiState = SweepUiState( - checkState = CheckState.Found(100000u), - sweepableBalances = SweepableBalances( - legacyBalance = 50000u, - legacyUtxosCount = 2u, - p2shBalance = 30000u, - p2shUtxosCount = 1u, - taprootBalance = 20000u, - taprootUtxosCount = 1u, - ), - ), - ) - } -} - -@Preview -@Composable -private fun PreviewNoFunds() { - AppThemeSurface { - Content( - uiState = SweepUiState(checkState = CheckState.NoFunds), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt deleted file mode 100644 index ead5d78ea..000000000 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/sweep/SweepSuccessScreen.kt +++ /dev/null @@ -1,127 +0,0 @@ -package to.bitkit.ui.settings.advanced.sweep - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.airbnb.lottie.compose.LottieAnimation -import com.airbnb.lottie.compose.LottieCompositionSpec -import com.airbnb.lottie.compose.rememberLottieComposition -import to.bitkit.R -import to.bitkit.ui.Routes -import to.bitkit.ui.components.BalanceHeaderView -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -fun SweepSuccessScreen( - navController: NavController, - amountSats: Long, -) { - Content( - amountSats = amountSats, - onDone = { - navController.navigate(Routes.Home) { - popUpTo(Routes.Home) { inclusive = true } - } - }, - ) -} - -@Composable -private fun Content( - amountSats: Long = 0L, - onDone: () -> Unit = {}, -) { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.confetti_orange)) - - Box(modifier = Modifier.fillMaxSize()) { - LottieAnimation( - composition = composition, - iterations = 100, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .testTag("confetti_animation") - ) - - ScreenColumn(noBackground = true) { - AppTopBar( - titleText = stringResource(R.string.sweep__success_nav_title), - onBackClick = null, - actions = { DrawerNavIcon() }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .testTag("SweepSuccess") - ) { - VerticalSpacer(16.dp) - - BodyM( - text = stringResource(R.string.sweep__success_description), - color = Colors.White64, - ) - - VerticalSpacer(16.dp) - - BalanceHeaderView(sats = amountSats) - - Spacer(modifier = Modifier.weight(1f)) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - Image( - painter = painterResource(R.drawable.check), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.size(256.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton( - text = stringResource(R.string.sweep__success_wallet_overview), - onClick = onDone, - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(16.dp) - } - } - } -} - -@Preview(showSystemUi = true, showBackground = true) -@Composable -private fun Preview() { - AppThemeSurface { - Content(amountSats = 18000L) - } -} diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt index 5613a4265..57fe9f4af 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt @@ -14,9 +14,10 @@ object ToastEventBus { description: String? = null, autoHide: Boolean = true, visibilityTime: Long = Toast.VISIBILITY_TIME_DEFAULT, + testTag: String? = null, ) { _events.emit( - Toast(type, title, description, autoHide, visibilityTime) + Toast(type, title, description, autoHide, visibilityTime, testTag) ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt deleted file mode 100644 index 14859ab62..000000000 --- a/app/src/main/java/to/bitkit/ui/sheets/SweepPromptSheet.kt +++ /dev/null @@ -1,121 +0,0 @@ -package to.bitkit.ui.sheets - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent - -@Composable -fun SweepPromptSheet( - onSweep: () -> Unit, - onCancel: () -> Unit, -) { - Content( - onSweep = onSweep, - onCancel = onCancel, - ) -} - -@Composable -private fun Content( - modifier: Modifier = Modifier, - onSweep: () -> Unit = {}, - onCancel: () -> Unit = {}, -) { - Column( - modifier = modifier - .sheetHeight() - .gradientBackground() - .navigationBarsPadding() - .padding(horizontal = 16.dp) - .testTag("SweepPromptSheet") - ) { - SheetTopBar(titleText = stringResource(R.string.sweep__nav_title)) - - Box( - contentAlignment = Alignment.BottomCenter, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Image( - painter = painterResource(R.drawable.coin_stack_x), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.size(311.dp) - ) - } - - VerticalSpacer(16.dp) - - Display(text = stringResource(R.string.sweep__prompt_title).withAccent()) - - VerticalSpacer(8.dp) - - BodyM( - text = stringResource(R.string.sweep__prompt_description), - color = Colors.White64, - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(32.dp) - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - modifier = Modifier - .weight(1f) - .testTag("CancelButton") - ) - PrimaryButton( - text = stringResource(R.string.sweep__prompt_sweep), - onClick = onSweep, - modifier = Modifier - .weight(1f) - .testTag("SweepButton") - ) - } - - VerticalSpacer(16.dp) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - BottomSheetPreview { - Content() - } - } -} diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index fd2cd74dd..5145111cb 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -1,10 +1,13 @@ package to.bitkit.usecases +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity +import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -19,40 +22,45 @@ import javax.inject.Singleton @Singleton class DeriveBalanceStateUseCase @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, ) { - suspend operator fun invoke(): Result = runCatching { - val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() - val channels = lightningRepo.getChannels().orEmpty() - val activeTransfers = transferRepo.activeTransfers.first() - - val paidOrdersSats = getOrderPaymentsSats(activeTransfers) - val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) - - val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) - val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() - - val totalOnchainSats = balanceDetails.totalOnchainBalanceSats - val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() - val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe() - - val balanceState = BalanceState( - totalOnchainSats = totalOnchainSats, - totalLightningSats = totalLightningSats, - maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), - maxSendOnchainSats = getMaxSendAmount(balanceDetails), - balanceInTransferToSavings = toSavingsAmount, - balanceInTransferToSpending = toSpendingAmount, - ) - - val height = lightningRepo.lightningState.value.block()?.height - Logger.verbose("Active transfers at block height=$height: ${jsonLogOf(activeTransfers)}", context = TAG) - Logger.verbose("Balances in ldk-node at block height=$height: ${jsonLogOf(balanceDetails)}", context = TAG) - Logger.verbose("Balances in state at block height=$height: ${jsonLogOf(balanceState)}", context = TAG) - - return@runCatching balanceState + suspend operator fun invoke(): Result = withContext(bgDispatcher) { + runCatching { + val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() + val channels = lightningRepo.getChannels().orEmpty() + val activeTransfers = transferRepo.activeTransfers.first() + + val paidOrdersSats = getOrderPaymentsSats(activeTransfers) + val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) + + val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) + val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() + + val totalOnchainSats = balanceDetails.totalOnchainBalanceSats + val channelFundableBalance = lightningRepo.getChannelFundableBalance() + val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() + val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe() + + val balanceState = BalanceState( + totalOnchainSats = totalOnchainSats, + channelFundableBalance = channelFundableBalance, + totalLightningSats = totalLightningSats, + maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + maxSendOnchainSats = getMaxSendAmount(balanceDetails), + balanceInTransferToSavings = toSavingsAmount, + balanceInTransferToSpending = toSpendingAmount, + ) + + val height = lightningRepo.lightningState.value.block()?.height + Logger.verbose("Active transfers at block height=$height: ${jsonLogOf(activeTransfers)}", context = TAG) + Logger.verbose("Balances in ldk-node at block height=$height: ${jsonLogOf(balanceDetails)}", context = TAG) + Logger.verbose("Balances in state at block height=$height: ${jsonLogOf(balanceState)}", context = TAG) + + return@runCatching balanceState + } } private fun getOrderPaymentsSats(transfers: List): ULong { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..82b9450a0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -98,7 +98,6 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo -import to.bitkit.repositories.SweepRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.AppUpdaterService @@ -150,7 +149,6 @@ class AppViewModel @Inject constructor( private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val migrationService: MigrationService, - private val sweepRepo: SweepRepo, private val coreService: CoreService, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, @@ -241,7 +239,7 @@ class AppViewModel @Inject constructor( init { viewModelScope.launch { ToastEventBus.events.collect { - toast(it.type, it.title, it.description, it.autoHide, it.visibilityTime) + toast(it) } } viewModelScope.launch { @@ -366,10 +364,17 @@ class AppViewModel @Inject constructor( val isShowingLoading = migrationService.isShowingMigrationLoading.value val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value val needsPostMigrationSync = migrationService.needsPostMigrationSync() + val pendingPrune = settingsStore.data.first().pendingRestoreAddressTypePrune when { (isShowingLoading || needsPostMigrationSync) && !isCompletingMigration -> completeMigration() isRestoringRemote -> completeRNRemoteBackupRestore() + pendingPrune -> { + settingsStore.update { it.copy(pendingRestoreAddressTypePrune = false) } + delay(POST_RESTORE_PRUNE_DELAY_MS) + lightningRepo.pruneEmptyAddressTypesAfterRestore() + walletRepo.debounceSyncByEvent() + } !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } @@ -403,7 +408,6 @@ class AppViewModel @Inject constructor( migrationService.cleanupAfterMigration() migrationService.setRestoringFromRNRemoteBackup(false) migrationService.setShowingMigrationLoading(false) - checkForSweepableFunds() } else { Logger.info("Post-migration sync incomplete (remote restore), will retry on next sync", context = TAG) migrationService.setShowingMigrationLoading(false) @@ -458,7 +462,6 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - checkForSweepableFunds() } else { Logger.info("Post-migration sync incomplete, will retry on next sync", context = TAG) migrationService.setShowingMigrationLoading(false) @@ -478,7 +481,6 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - checkForSweepableFunds() } else { Logger.info("Post-migration sync incomplete (fallback), will retry on next sync", context = TAG) migrationService.setShowingMigrationLoading(false) @@ -496,13 +498,6 @@ class AppViewModel @Inject constructor( ) } - fun checkForSweepableFunds() { - viewModelScope.launch(bgDispatcher) { - sweepRepo.hasSweepableFunds() - .onSuccess { hasFunds -> if (hasFunds) showSheet(Sheet.SweepPrompt) } - } - } - private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) } @@ -2074,7 +2069,8 @@ class AppViewModel @Inject constructor( title = toast.title, description = toast.description, autoHide = toast.autoHide, - visibilityTime = toast.visibilityTime + visibilityTime = toast.visibilityTime, + testTag = toast.testTag, ) } @@ -2278,6 +2274,7 @@ class AppViewModel @Inject constructor( private const val MAX_FEE_AMOUNT_RATIO = 0.5 private const val SCREEN_TRANSITION_DELAY_MS = 300L private const val MIGRATION_LOADING_TIMEOUT_MS = 120_000L + private const val POST_RESTORE_PRUNE_DELAY_MS = 30_000L private const val MIGRATION_AUTH_RESET_DELAY_MS = 500L private const val REMOTE_RESTORE_NODE_RESTART_DELAY_MS = 500L private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L diff --git a/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt deleted file mode 100644 index b6285854c..000000000 --- a/app/src/main/java/to/bitkit/viewmodels/SweepViewModel.kt +++ /dev/null @@ -1,226 +0,0 @@ -package to.bitkit.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.FeeRates -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import to.bitkit.models.TransactionSpeed -import to.bitkit.models.safe -import to.bitkit.repositories.LightningRepo -import to.bitkit.repositories.SweepRepo -import to.bitkit.utils.Logger -import javax.inject.Inject - -@HiltViewModel -class SweepViewModel @Inject constructor( - private val sweepRepo: SweepRepo, - private val lightningRepo: LightningRepo, -) : ViewModel() { - private val _uiState = MutableStateFlow(SweepUiState()) - val uiState = _uiState.asStateFlow() - - fun checkBalance() = viewModelScope.launch { - _uiState.update { it.copy(checkState = CheckState.Checking) } - - sweepRepo.checkSweepableBalances().fold( - onSuccess = { balances -> - if (balances.totalBalance > 0u) { - _uiState.update { - it.copy( - checkState = CheckState.Found(balances.totalBalance), - sweepableBalances = balances, - ) - } - } else { - _uiState.update { it.copy(checkState = CheckState.NoFunds) } - } - }, - onFailure = { error -> - Logger.error("Failed to check sweepable balance", error, context = TAG) - _uiState.update { it.copy(checkState = CheckState.Error(error.message ?: "Unknown error")) } - } - ) - } - - fun prepareSweep() = viewModelScope.launch { - _uiState.update { it.copy(sweepState = SweepState.Preparing) } - - val selectedFeeRate = _uiState.value.selectedFeeRate - if (selectedFeeRate == null || selectedFeeRate == 0u) { - val error = "Fee rate not set" - _uiState.update { it.copy(sweepState = SweepState.Error(error), errorMessage = error) } - return@launch - } - - val existingAddress = _uiState.value.destinationAddress - var destinationAddress = existingAddress - if (existingAddress == null) { - lightningRepo.newAddress().fold( - onSuccess = { address -> - destinationAddress = address - _uiState.update { it.copy(destinationAddress = address) } - }, - onFailure = { error -> - Logger.error("Failed to get destination address", error, context = TAG) - val errorMsg = "Failed to get destination address" - _uiState.update { - it.copy(sweepState = SweepState.Error(errorMsg), errorMessage = errorMsg) - } - return@launch - } - ) - } - - if (destinationAddress == null) return@launch - - sweepRepo.prepareSweepTransaction( - destinationAddress = destinationAddress, - feeRateSatsPerVbyte = selectedFeeRate, - ).fold( - onSuccess = { preview -> - _uiState.update { - it.copy( - sweepState = SweepState.Ready, - transactionPreview = preview, - ) - } - }, - onFailure = { error -> - Logger.error("Failed to prepare sweep", error, context = TAG) - _uiState.update { - it.copy( - sweepState = SweepState.Error(error.message ?: "Unknown error"), - errorMessage = error.message, - ) - } - } - ) - } - - fun broadcastSweep() = viewModelScope.launch { - val preview = _uiState.value.transactionPreview - if (preview == null) { - _uiState.update { it.copy(sweepState = SweepState.Error("No transaction prepared")) } - return@launch - } - - _uiState.update { it.copy(sweepState = SweepState.Broadcasting) } - - sweepRepo.broadcastSweepTransaction(preview.psbt).fold( - onSuccess = { result -> - _uiState.update { - it.copy( - sweepState = SweepState.Success(result.txid), - sweepResult = result, - ) - } - }, - onFailure = { error -> - Logger.error("Failed to broadcast sweep", error, context = TAG) - _uiState.update { - it.copy( - sweepState = SweepState.Error(error.message ?: "Unknown error"), - errorMessage = error.message, - ) - } - } - ) - } - - fun setFeeRate(speed: TransactionSpeed) { - _uiState.update { it.copy(selectedSpeed = speed) } - - val feeRate: UInt = when (speed) { - is TransactionSpeed.Custom -> speed.satsPerVByte - else -> { - val rates = _uiState.value.feeRates ?: return - speed.getFeeRate(rates) - } - } - _uiState.update { it.copy(selectedFeeRate = feeRate) } - } - - fun loadFeeEstimates() = viewModelScope.launch { - sweepRepo.getFeeRates().fold( - onSuccess = { rates -> - _uiState.update { it.copy(feeRates = rates) } - if (_uiState.value.selectedFeeRate == null) { - setFeeRate(_uiState.value.selectedSpeed) - } - }, - onFailure = { error -> - Logger.error("Failed to load fee estimates", error, context = TAG) - _uiState.update { it.copy(errorMessage = error.message) } - } - ) - } - - fun reset() { - _uiState.update { SweepUiState() } - } - - companion object { - private const val TAG = "SweepViewModel" - } -} - -data class SweepUiState( - val checkState: CheckState = CheckState.Idle, - val sweepState: SweepState = SweepState.Idle, - val sweepableBalances: SweepableBalances? = null, - val transactionPreview: SweepTransactionPreview? = null, - val sweepResult: SweepResult? = null, - val destinationAddress: String? = null, - val selectedSpeed: TransactionSpeed = TransactionSpeed.Medium, - val selectedFeeRate: UInt? = null, - val feeRates: FeeRates? = null, - val errorMessage: String? = null, -) { - val estimatedFee: ULong get() = transactionPreview?.estimatedFee ?: 0u -} - -sealed interface CheckState { - data object Idle : CheckState - data object Checking : CheckState - data class Found(val balance: ULong) : CheckState - data object NoFunds : CheckState - data class Error(val message: String) : CheckState -} - -sealed interface SweepState { - data object Idle : SweepState - data object Preparing : SweepState - data object Ready : SweepState - data object Broadcasting : SweepState - data class Success(val txid: String) : SweepState - data class Error(val message: String) : SweepState -} - -data class SweepableBalances( - val legacyBalance: ULong = 0u, - val legacyUtxosCount: UInt = 0u, - val p2shBalance: ULong = 0u, - val p2shUtxosCount: UInt = 0u, - val taprootBalance: ULong = 0u, - val taprootUtxosCount: UInt = 0u, -) { - val totalBalance: ULong - get() = listOf(legacyBalance, p2shBalance, taprootBalance) - .fold(0uL) { acc, balance -> (acc.safe() + balance.safe()) } -} - -data class SweepTransactionPreview( - val psbt: String, - val estimatedFee: ULong, - val amountAfterFees: ULong, - val estimatedVsize: ULong, -) - -data class SweepResult( - val txid: String, - val amountSwept: ULong, -) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e5d5946b7..b754e9c3f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -28,7 +28,6 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore -import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -195,17 +194,20 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() - // Calculate if change would be dust and we should use sendAll val spendableBalance = lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL - val txFee = lightningRepo.calculateTotalFee( - amountSats = order.feeSat, + val allUtxos = lightningRepo.listSpendableOutputs().getOrNull() + val sendAllFee = lightningRepo.calculateTotalFee( + amountSats = spendableBalance, address = address, speed = speed, + utxosToSpend = allUtxos, ).getOrElse { 0uL } - val expectedChange = spendableBalance.toLong() - order.feeSat.toLong() - txFee.toLong() - val shouldUseSendAll = expectedChange >= 0 && expectedChange < Defaults.dustLimit.toInt() + val expectedChange = + spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() + val shouldUseSendAll = + expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS lightningRepo .sendOnChain( @@ -610,6 +612,7 @@ class TransferViewModel @Inject constructor( companion object { private const val TAG = "TransferViewModel" + private const val TRANSFER_SEND_ALL_THRESHOLD_SATS = 1000 private const val MIN_STEP_DELAY_MS = 500L private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index cd2b5ee43..1ee822207 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -221,7 +221,12 @@ class WalletViewModel @Inject constructor( backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) } - fun onRestoreContinue() = _restoreState.update { RestoreState.Settled } + fun onRestoreContinue() { + viewModelScope.launch(bgDispatcher) { + settingsStore.update { it.copy(pendingRestoreAddressTypePrune = true) } + } + _restoreState.update { RestoreState.Settled } + } fun onRestoreRetry() = viewModelScope.launch(bgDispatcher) { _restoreState.update { it.countRetry() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d080f226..9eadb617a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -534,6 +534,16 @@ No addresses found when searching for \"{searchTxt}\" Path: {path} {count, plural, one {Spend ₿ {fundsToSpend} From # address} other {Spend ₿ {fundsToSpend} From # addresses}} + Applying changes… + Cannot disable monitoring: address type is currently selected + Cannot disable monitoring: address type has balance + At least one Native SegWit or Taproot wallet is required for Lightning channels. + Cannot disable monitoring: failed to verify balance + Monitor address types + Primary address type + Settings updated + Address type + Currently selected Address Viewer Coin Selection Autopilot diff --git a/app/src/test/java/to/bitkit/models/AddressTypeTest.kt b/app/src/test/java/to/bitkit/models/AddressTypeTest.kt new file mode 100644 index 000000000..4b4166fee --- /dev/null +++ b/app/src/test/java/to/bitkit/models/AddressTypeTest.kt @@ -0,0 +1,72 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.AddressType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AddressTypeTest { + + @Test + fun `toSettingsString maps all types correctly`() { + assertEquals("taproot", AddressType.P2TR.toSettingsString()) + assertEquals("nativeSegwit", AddressType.P2WPKH.toSettingsString()) + assertEquals("nestedSegwit", AddressType.P2SH.toSettingsString()) + assertEquals("legacy", AddressType.P2PKH.toSettingsString()) + } + + @Test + fun `toAddressType maps all strings correctly`() { + assertEquals(AddressType.P2TR, "taproot".toAddressType()) + assertEquals(AddressType.P2WPKH, "nativeSegwit".toAddressType()) + assertEquals(AddressType.P2SH, "nestedSegwit".toAddressType()) + assertEquals(AddressType.P2PKH, "legacy".toAddressType()) + } + + @Test + fun `toAddressType returns null for unknown strings`() { + assertNull("unknown".toAddressType()) + assertNull("".toAddressType()) + } + + @Test + fun `toSettingsString and toAddressType are inverses`() { + val types = listOf(AddressType.P2TR, AddressType.P2WPKH, AddressType.P2SH, AddressType.P2PKH) + types.forEach { type -> + assertEquals(type, type.toSettingsString().toAddressType()) + } + } + + @Test + fun `addressTypeFromAddress detects taproot addresses`() { + assertEquals("taproot", "bc1pabc123".addressTypeFromAddress()) + assertEquals("taproot", "tb1pdef456".addressTypeFromAddress()) + assertEquals("taproot", "bcrt1pabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects native segwit addresses`() { + assertEquals("nativeSegwit", "bc1qabc123".addressTypeFromAddress()) + assertEquals("nativeSegwit", "tb1qabc123".addressTypeFromAddress()) + assertEquals("nativeSegwit", "bcrt1qabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects nested segwit addresses`() { + assertEquals("nestedSegwit", "3abc123".addressTypeFromAddress()) + assertEquals("nestedSegwit", "2abc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress detects legacy addresses`() { + assertEquals("legacy", "1abc123".addressTypeFromAddress()) + assertEquals("legacy", "mabc123".addressTypeFromAddress()) + assertEquals("legacy", "nabc123".addressTypeFromAddress()) + } + + @Test + fun `addressTypeFromAddress returns null for unknown`() { + assertNull("xabc123".addressTypeFromAddress()) + assertNull("".addressTypeFromAddress()) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 18a441819..53eb38ceb 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2,14 +2,20 @@ package to.bitkit.repositories import app.cash.turbine.test import com.google.firebase.messaging.FirebaseMessaging +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.ILspNode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.AddressTypeBalance +import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails @@ -680,6 +686,386 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull(), anyOrNull()) } + @Test + fun `getBalanceForAddressType should succeed when node is running`() = test { + startNodeForTesting() + whenever(lightningService.getBalanceForAddressType(AddressType.P2WPKH)) + .thenReturn(AddressTypeBalance(totalSats = 50_000uL, spendableSats = 50_000uL)) + + val result = sut.getBalanceForAddressType(AddressType.P2WPKH) + + assertTrue(result.isSuccess) + assertEquals(50_000uL, result.getOrNull()) + } + + @Test + fun `getBalanceForAddressType should fail when node is not running`() = test { + val result = sut.getBalanceForAddressType(AddressType.P2WPKH) + + assertTrue(result.isFailure) + } + + @Test + fun `getChannelFundableBalance should return aggregate spendable when per-type fails`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf(SettingsData(selectedAddressType = "nativeSegwit", addressTypesToMonitor = listOf("nativeSegwit"))) + ) + whenever(lightningService.getBalanceForAddressType(any())) + .thenThrow(UnsupportedOperationException("per-type not supported")) + whenever(lightningService.balances).thenReturn( + BalanceDetails( + totalOnchainBalanceSats = 100_000uL, + spendableOnchainBalanceSats = 80_000uL, + totalAnchorChannelsReserveSats = 0uL, + totalLightningBalanceSats = 0uL, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ), + ) + + val result = sut.getChannelFundableBalance() + + assertEquals(80_000uL, result) + } + + @Test + fun `updateAddressType should fail when already in progress`() = test { + startNodeForTesting() + val settingsFlow = MutableSharedFlow() + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + + val scope = CoroutineScope(testDispatcher) + val job1 = scope.async { + sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) + } + testScheduler.advanceUntilIdle() + val job2 = scope.async { sut.updateAddressType("legacy", listOf("legacy")) } + val result2 = job2.await() + settingsFlow.emit( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + ), + ) + val result1 = job1.await() + + assertTrue(result2.isFailure) + assertTrue(result2.exceptionOrNull()?.message?.contains("already in progress") == true) + assertTrue(result1.isSuccess) + } + + @Test + fun `setMonitoring should fail when disabling currently selected type`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "taproot", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("currently selected") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should fail when disabling last required native witness`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "legacy", + addressTypesToMonitor = listOf("taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Native SegWit or Taproot") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should fail when balance verification fails`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenThrow(RuntimeException("balance check failed")) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("verify") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should fail when disabling with balance greater than zero`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 1_000uL, spendableSats = 1_000uL)) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("has balance") == true) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should succeed when enabling a type`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit") + ) + ) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = true) + + assertTrue(result.isSuccess) + verify(lightningService).addAddressTypeToMonitor(AddressType.P2TR) + } + + @Test + fun `setMonitoring should succeed when disabling when allowed`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = false) + + assertTrue(result.isSuccess) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2TR) + } + + @Test + fun `updateAddressType should succeed`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf(SettingsData(selectedAddressType = "nativeSegwit", addressTypesToMonitor = listOf("nativeSegwit"))) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + + val result = sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) + + assertTrue(result.isSuccess) + verify(lightningService).setPrimaryAddressType(AddressType.P2TR) + } + + @Test + fun `updateAddressType should fail when setPrimaryAddressType fails`() = test { + startNodeForTesting() + whenever( + settingsStore.data + ).thenReturn( + flowOf(SettingsData(selectedAddressType = "nativeSegwit", addressTypesToMonitor = listOf("nativeSegwit"))) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(lightningService.setPrimaryAddressType(any())) + .thenThrow(RuntimeException("setPrimaryAddressType failed")) + + val result = sut.updateAddressType("taproot", listOf("taproot", "nativeSegwit")) + + assertTrue(result.isFailure) + // Verify rollback happened (update called twice: once for new settings, once for rollback) + verifyBlocking(settingsStore, times(2)) { update(any()) } + } + + @Test + fun `getChannelFundableBalance should sum spendable across monitored types excluding legacy`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot", "legacy") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2WPKH)) + .thenReturn(AddressTypeBalance(totalSats = 50_000uL, spendableSats = 40_000uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 30_000uL, spendableSats = 20_000uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2PKH)) + .thenReturn(AddressTypeBalance(totalSats = 10_000uL, spendableSats = 10_000uL)) + + val result = sut.getChannelFundableBalance() + + // 40_000 (P2WPKH) + 20_000 (P2TR) = 60_000; legacy 10_000 excluded + assertEquals(60_000uL, result) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should remove empty non-selected types`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot", "legacy") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + whenever(lightningService.getBalanceForAddressType(AddressType.P2PKH)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2TR) + verify(lightningService).removeAddressTypeFromMonitor(AddressType.P2PKH) + // Selected type (nativeSegwit/P2WPKH) must not be removed + verify(lightningService, times(0)).removeAddressTypeFromMonitor(AddressType.P2WPKH) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should keep types with balance`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2TR)) + .thenReturn(AddressTypeBalance(totalSats = 5_000uL, spendableSats = 5_000uL)) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should not remove last native witness type`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "legacy", + addressTypesToMonitor = listOf("legacy", "nativeSegwit") + ) + ) + ) + whenever(lightningService.getBalanceForAddressType(AddressType.P2WPKH)) + .thenReturn(AddressTypeBalance(totalSats = 0uL, spendableSats = 0uL)) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `pruneEmptyAddressTypesAfterRestore should skip when address type change in progress`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot") + ) + ) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + // Start an address type change to set isChangingAddressType + val settingsFlow = MutableSharedFlow() + whenever(settingsStore.data).thenReturn(settingsFlow) + val scope = CoroutineScope(testDispatcher) + scope.async { sut.updateAddressType("taproot", listOf("taproot")) } + testScheduler.advanceUntilIdle() + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) + } + + @Test + fun `setMonitoring should rollback on service failure`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit") + ) + ) + ) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(lightningService.addAddressTypeToMonitor(any())) + .thenThrow(RuntimeException("service error")) + + val result = sut.setMonitoring(AddressType.P2TR, enabled = true) + + assertTrue(result.isFailure) + // Verify rollback happened (update called twice: once for new, once for rollback) + verifyBlocking(settingsStore, times(2)) { update(any()) } + } + @Test fun `start should not retry when node lifecycle state is Running`() = test { sut.setInitNodeLifecycleState() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c0b4e19d4..fbbb03f04 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -1,6 +1,9 @@ package to.bitkit.repositories import app.cash.turbine.test +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.GetAddressResponse +import com.synonym.bitkitcore.GetAddressesResponse import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -11,6 +14,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -37,7 +41,6 @@ class WalletRepoTest : BaseUnitTest() { private val keychain = mock() private val coreService = mock() - private val onchainService = mock() private val settingsStore = mock() private val lightningRepo = mock() private val cacheStore = mock() @@ -45,6 +48,7 @@ class WalletRepoTest : BaseUnitTest() { private val deriveBalanceStateUseCase = mock() private val wipeWalletUseCase = mock() private val transferRepo = mock() + private val onchainService = mock() companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -82,6 +86,7 @@ class WalletRepoTest : BaseUnitTest() { .thenReturn(Result.success(SATS)) whenever(lightningRepo.canReceive()).thenReturn(false) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever { settingsStore.update(any()) }.thenReturn(Unit) whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(BalanceState())) whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") @@ -95,6 +100,24 @@ class WalletRepoTest : BaseUnitTest() { whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(preActivityMetadataRepo.resetPreActivityMetadataTags(any())).thenReturn(Result.success(Unit)) whenever(preActivityMetadataRepo.deletePreActivityMetadata(any())).thenReturn(Result.success(Unit)) + val mockAddressForGetAddresses = mock { + on { address } doReturn "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" + on { path } doReturn "m/84'/0'/0'/0/0" + } + val mockGetAddressesResponse = mock { + on { addresses } doReturn listOf(mockAddressForGetAddresses) + } + whenever { + onchainService.deriveBitcoinAddresses( + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + }.thenReturn(mockGetAddressesResponse) sut = createSut() } @@ -167,14 +190,23 @@ class WalletRepoTest : BaseUnitTest() { } @Test - fun `restoreWallet should not call settingsStore`() = test { + fun `restoreWallet should monitor all address types for restore`() = test { val mnemonic = "restore mnemonic" whenever(keychain.saveString(any(), any())).thenReturn(Unit) + var capturedSettings: SettingsData? = null + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + capturedSettings = transform(SettingsData()) + } val result = sut.restoreWallet(mnemonic, null) assertTrue(result.isSuccess) - verify(settingsStore, never()).update(any()) + assertEquals("nativeSegwit", capturedSettings?.selectedAddressType) + assertEquals( + listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot"), + capturedSettings?.addressTypesToMonitor, + ) } @Test @@ -591,4 +623,38 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + + @Test + fun `getAddresses should call deriveBitcoinAddresses with P2WPKH path by default`() = test { + val result = sut.getAddresses() + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.size) + verify(onchainService).deriveBitcoinAddresses( + any(), + argThat { path -> path?.contains("m/84") == true }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `getAddresses should call deriveBitcoinAddresses with P2TR path when addressType is Taproot`() = test { + val result = sut.getAddresses(addressType = AddressType.P2TR) + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.size) + verify(onchainService).deriveBitcoinAddresses( + any(), + argThat { path -> path?.contains("m/86") == true }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } } diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt new file mode 100644 index 000000000..b1fa5af72 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -0,0 +1,284 @@ +package to.bitkit.ui.settings.advanced + +import android.content.Context +import app.cash.turbine.test +import com.synonym.bitkitcore.AddressType +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.Toast +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AddressTypePreferenceViewModelTest : BaseUnitTest() { + private val context: Context = mock() + private val settingsStore: SettingsStore = mock() + private val lightningRepo: LightningRepo = mock() + private val walletRepo: WalletRepo = mock() + + private lateinit var sut: AddressTypePreferenceViewModel + + private val applyingChanges = "Applying changes…" + private val settingsUpdated = "Settings updated" + private val errorTitle = "Error" + private val disabledHasBalance = "Address type has balance" + private val disabledVerifyFailed = "Failed to verify balance" + private val disabledNativeRequired = "Native SegWit or Taproot required" + private val disabledCurrentlySelected = "Currently selected" + + @Before + fun setUp() { + whenever(context.getString(R.string.settings__addr_type__applying)).thenReturn(applyingChanges) + whenever(context.getString(R.string.settings__addr_type__settings_updated)).thenReturn(settingsUpdated) + whenever(context.getString(R.string.common__error)).thenReturn(errorTitle) + whenever(context.getString(R.string.settings__addr_type__disabled_has_balance)) + .thenReturn(disabledHasBalance) + whenever( + context.getString(R.string.settings__addr_type__disabled_verify_failed) + ).thenReturn(disabledVerifyFailed) + whenever( + context.getString(R.string.settings__addr_type__disabled_native_required) + ).thenReturn(disabledNativeRequired) + whenever( + context.getString(R.string.settings__addr_type__disabled_currently_selected) + ).thenReturn(disabledCurrentlySelected) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + } + + private fun createSut(): AddressTypePreferenceViewModel = + AddressTypePreferenceViewModel( + context = context, + bgDispatcher = testDispatcher, + settingsStore = settingsStore, + lightningRepo = lightningRepo, + walletRepo = walletRepo, + ) + + @Test + fun `loadState populates uiState from settings`() = test { + sut = createSut() + + sut.uiState.test { + val state = awaitItem() + assertEquals(AddressType.P2WPKH, state.selectedAddressType) + assertEquals(setOf("nativeSegwit"), state.monitoredTypes) + assertTrue(state.showMonitoredTypes) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `setMonitoring success sends success toast`() = test { + whenever(lightningRepo.setMonitoring(AddressType.P2TR, true)) + .thenReturn(Result.success(Unit)) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.setMonitoring(AddressType.P2TR, true) + advanceUntilIdle() + + assertTrue(toasts.size >= 2) + assertEquals(Toast.ToastType.INFO, toasts.first().type) + assertEquals(applyingChanges, toasts.first().title) + assertEquals(Toast.ToastType.SUCCESS, toasts.last().type) + assertEquals(settingsUpdated, toasts.last().title) + collectJob.cancel() + } + + @Test + fun `setMonitoring failure sends error toast with mapped message`() = test { + whenever(lightningRepo.setMonitoring(AddressType.P2TR, false)) + .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type has balance"))) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit", "taproot"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.setMonitoring(AddressType.P2TR, false) + advanceUntilIdle() + + assertTrue(toasts.isNotEmpty()) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals(disabledHasBalance, toasts.last().description) + collectJob.cancel() + } + + @Test + fun `setMonitoring failure with currently selected sends mapped error toast`() = test { + whenever(lightningRepo.setMonitoring(AddressType.P2TR, false)) + .thenReturn(Result.failure(Exception("Cannot disable monitoring: address type is currently selected"))) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "taproot", + addressTypesToMonitor = listOf("nativeSegwit", "taproot"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.setMonitoring(AddressType.P2TR, false) + advanceUntilIdle() + + assertTrue(toasts.isNotEmpty()) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals(disabledCurrentlySelected, toasts.last().description) + collectJob.cancel() + } + + @Test + fun `updateAddressType success sends success toast`() = test { + whenever(lightningRepo.updateAddressType(any(), any())).thenReturn(Result.success(Unit)) + whenever(walletRepo.refreshReceiveAddressAfterTypeChange()).thenReturn(Result.success(Unit)) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.updateAddressType(AddressType.P2TR) + advanceUntilIdle() + + assertTrue(toasts.size >= 2) + assertEquals(Toast.ToastType.INFO, toasts.first().type) + assertEquals(applyingChanges, toasts.first().title) + assertEquals(Toast.ToastType.SUCCESS, toasts.last().type) + assertEquals(settingsUpdated, toasts.last().title) + verify(walletRepo).refreshReceiveAddressAfterTypeChange() + collectJob.cancel() + } + + @Test + fun `updateAddressType failure sends error toast`() = test { + whenever(lightningRepo.updateAddressType(any(), any())) + .thenReturn(Result.failure(Exception("Update failed"))) + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.updateAddressType(AddressType.P2TR) + advanceUntilIdle() + + assertTrue(toasts.isNotEmpty()) + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(errorTitle, toasts.last().title) + assertEquals("Update failed", toasts.last().description) + verify(walletRepo, times(0)).refreshReceiveAddressAfterTypeChange() + collectJob.cancel() + } + + @Test + fun `updateAddressType no-op when same type already selected`() = test { + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.updateAddressType(AddressType.P2WPKH) // same as selected + advanceUntilIdle() + + assertTrue(toasts.isEmpty(), "No toast should be sent for no-op") + verify(lightningRepo, never()).updateAddressType(any(), any()) + collectJob.cancel() + } + + @Test + fun `setMonitoring no-op when type already in desired state`() = test { + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + isDevModeEnabled = true, + ) + ) + ) + sut = createSut() + advanceUntilIdle() + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + sut.setMonitoring(AddressType.P2WPKH, true) // already monitored + advanceUntilIdle() + + assertTrue(toasts.isEmpty(), "No toast should be sent for no-op") + verify(lightningRepo, never()).setMonitoring(any(), any()) + collectJob.cancel() + } +} diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b9a7e48c3..c5e0fc700 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -2,6 +2,7 @@ package to.bitkit.usecases import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails @@ -35,14 +36,24 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { @Before fun setUp() { - whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) - wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) - wheneverBlocking { lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull()) } - .thenReturn(Result.success(1000uL)) - + runBlocking { + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) + wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL) + wheneverBlocking { + lightningRepo.calculateTotalFee( + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + }.thenReturn(Result.success(1000uL)) + } sut = DeriveBalanceStateUseCase( + bgDispatcher = testDispatcher, lightningRepo = lightningRepo, transferRepo = transferRepo, settingsStore = settingsStore, @@ -82,6 +93,27 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should include channelFundableBalance from lightning repo in state`() = test { + val balance = BalanceDetails( + totalOnchainBalanceSats = 100_000u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 10_000u, + totalLightningBalanceSats = 50_000u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + whenever(lightningRepo.getChannelFundableBalance()).thenReturn(25_000uL) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + + val result = sut() + + assertTrue(result.isSuccess) + assertEquals(25_000uL, result.getOrThrow().channelFundableBalance) + } + @Test fun `should calculate manual channel transfer to spending using channel balance`() = test { val channelId = "manual-channel-id" diff --git a/docs/address-types.md b/docs/address-types.md new file mode 100644 index 000000000..566e720cd --- /dev/null +++ b/docs/address-types.md @@ -0,0 +1,45 @@ +# Multi-Address Types + +Users can select and monitor different Bitcoin address formats (Legacy/P2PKH, Nested SegWit/P2SH-P2WPKH, Native SegWit/P2WPKH, Taproot/P2TR). The implementation affects receive addresses, balance aggregation, channel funding, and channel closure. + +## Features + +- **Primary address type**: The address format used when generating new receive addresses (Legacy, Nested SegWit, Native SegWit, Taproot). +- **Monitoring**: Which address types to include in balance and channel funding. Legacy is excluded from channel funding per design. + +## Settings + +Stored in `SettingsStore`: + +- `selectedAddressType`: Primary receive address type (`"legacy"`, `"nestedSegwit"`, `"nativeSegwit"`, `"taproot"`). +- `addressTypesToMonitor`: List of address types to include in balance aggregation and channel funding. + +## Balance and Channel Funding + +- **Channel fundable balance**: Sum of spendable sats for selected + monitored types, excluding legacy. Used in FundingScreen for the "Transfer" button availability. +- **Total onchain balance**: Aggregate across all monitored types (from ldk-node when available). +- When per-type balance API is unavailable, channel fundable balance falls back to aggregate spendable. + +## Address Type Preference Screen + +- **Primary selection**: Radio list of address types. Change triggers node restart with rollback on failure. +- **Monitoring toggles**: Enable/disable monitoring per type. Disabling requires balance to be zero; balance check must succeed. +- **Loading**: 60s timeout with toast on timeout or failure. + +## Restart and Recovery + +- Changing address type or monitoring requires node restart. +- **Rollback on failure**: Previous settings restored if restart fails. +- **Mutual exclusion**: Only one address type change in progress at a time. + +## Testing Matrix + +| Flow | Legacy | Nested SegWit | Native SegWit | Taproot | +| ------------- | ------ | ------------- | ------------- | --------------------------- | +| Receive | ✓ | ✓ | ✓ | ✓ | +| Send | ✓ | ✓ | ✓ | ✓ | +| Channel open | - | ✓ | ✓ | ✓ | +| Channel close | - | Native SegWit | Native SegWit | Taproot if selected at open | +| CPFP/RBF | ✓ | ✓ | ✓ | ✓ | + +Manual verification recommended for each address type and combinations. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 029621f5c..99038db01 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.18" } +ldk-node-android = { module = "com.github.synonymdev.ldk-node:ldk-node-android", version = "v0.7.0-rc.25" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }