Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/e2e_migration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = listOf("nativeSegwit"),
val pendingRestoreAddressTypePrune: Boolean = false,
)

fun SettingsData.resetPin() = this.copy(
Expand Down
44 changes: 38 additions & 6 deletions app/src/main/java/to/bitkit/models/AddressType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ data class AddressTypeInfo(
val shortName: String,
val description: String,
val example: String,
val shortExample: String,
)

@Suppress("unused")
Expand All @@ -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(
Expand All @@ -54,6 +59,7 @@ fun AddressType.addressTypeInfo(): AddressTypeInfo = when (this) {
shortName = "Unknown",
description = "Unknown",
example = "",
shortExample = "",
)
}

Expand All @@ -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
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/BalanceState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
181 changes: 177 additions & 4 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -113,6 +118,7 @@ class LightningRepo @Inject constructor(
private val syncPending = AtomicBoolean(false)
private val syncRetryJob = AtomicReference<Job?>(null)
private val lifecycleMutex = Mutex()
private val isChangingAddressType = AtomicBoolean(false)

init {
observeConnectivityForSyncRetry()
Expand Down Expand Up @@ -628,6 +634,170 @@ class LightningRepo @Inject constructor(
}
}

suspend fun getBalanceForAddressType(addressType: AddressType): Result<ULong> = 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<String>,
): Result<Unit> = 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<Unit> = 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<String>,
): 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<Unit> = 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<String>): 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<Unit> = withContext(bgDispatcher) {
Logger.debug("Stopping node for recovery attempt", context = TAG)

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading