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
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ data class AppCacheData(
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
val quickPayLedger: QuickPayLedger? = null,
val blocktankRefundAddress: BlocktankRefundAddress? = null,
) {
fun isActivityDeleted(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in deletedActivities ||
Expand All @@ -177,3 +178,9 @@ data class AppCacheData(

fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "")
}

@Serializable
data class BlocktankRefundAddress(
val address: String,
val index: Long,
)
10 changes: 8 additions & 2 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ class SettingsStore @Inject constructor(

suspend fun restoreFromBackup(payload: SettingsBackupV1) =
runCatching {
val data = payload.settings.resetPin().withDefaultPaykitPaymentMethods()
val data = payload.settings.resetPin()
.withDefaultPaykitPaymentMethods()
.withRequiredNativeSegwitMonitoring()
store.updateData { data }

val monitored = data.addressTypesToMonitor
Expand All @@ -59,7 +61,7 @@ class SettingsStore @Inject constructor(
}

suspend fun update(transform: (SettingsData) -> SettingsData) {
store.updateData(transform)
store.updateData { transform(it).withRequiredNativeSegwitMonitoring() }
}

suspend fun setIsPaykitEnabled(value: Boolean) {
Expand Down Expand Up @@ -174,6 +176,10 @@ fun SettingsData.withDefaultPaykitPaymentMethods() = copy(
publicPaykitOnchainEnabled = true,
)

fun SettingsData.withRequiredNativeSegwitMonitoring() = copy(
addressTypesToMonitor = (addressTypesToMonitor + DEFAULT_ADDRESS_TYPE_STRING).distinct(),
)

fun SettingsData.hasPublicPaykitPublicationState(): Boolean =
hasConfirmedPublicPaykitEndpoints ||
sharesPublicPaykitEndpoints ||
Expand Down
64 changes: 60 additions & 4 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.repositories

import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.BtOrderState2
import com.synonym.bitkitcore.CJitStateEnum
import com.synonym.bitkitcore.ChannelLiquidityOptions
Expand All @@ -27,6 +28,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -41,6 +43,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
Expand All @@ -49,6 +53,7 @@ import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
import to.bitkit.async.ServiceQueue
import to.bitkit.async.appScope
import to.bitkit.data.BlocktankRefundAddress
import to.bitkit.data.CacheStore
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
Expand All @@ -61,6 +66,7 @@ import to.bitkit.models.msatCeilOf
import to.bitkit.models.safe
import to.bitkit.services.CoreService
import to.bitkit.services.LightningService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import java.math.BigDecimal
Expand All @@ -84,6 +90,7 @@ class BlocktankRepo @Inject constructor(
private val lightningRepo: LightningRepo,
) {
private val repoScope = appScope(bgDispatcher, TAG)
private val refundAddressMutex = Mutex()

private val _blocktankState = MutableStateFlow(BlocktankState())
val blocktankState: StateFlow<BlocktankState> = _blocktankState.asStateFlow()
Expand Down Expand Up @@ -312,16 +319,25 @@ class BlocktankRepo @Inject constructor(
receivingBalanceSats: ULong = spendingBalanceSats * 2u,
channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS,
): Result<IBtOrder> = withContext(bgDispatcher) {
runCatching {
runSuspendCatching {
if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked()
if (lightningService.nodeId == null) throw ServiceError.NodeNotStarted()

val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats)
currentCoroutineContext().ensureActive()
val refundAddress = getBlocktankRefundAddress()
currentCoroutineContext().ensureActive()
val baseOptions = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats)
val options = baseOptions.copy(refundOnchainAddress = refundAddress)
currentCoroutineContext().ensureActive()

Logger.info(
"Buying channel with " +
"clientBalanceSat: '$spendingBalanceSats', " +
"lspBalanceSat: '$receivingBalanceSats', " +
"channelExpiryWeeks: '$channelExpiryWeeks', " +
"options: '$options'",
"zeroConf: '${options.zeroConf}', " +
"zeroReserve: '${options.zeroReserve}', " +
"announceChannel: '${options.announceChannel}'",
context = TAG,
)

Expand All @@ -333,12 +349,52 @@ class BlocktankRepo @Inject constructor(

repoScope.launch { refreshOrders() }

return@runCatching order
return@runSuspendCatching order
}.onFailure {
Logger.error("Failed to create order", it, context = TAG)
}
}

private suspend fun getBlocktankRefundAddress(): String = refundAddressMutex.withLock {
val cached = cacheStore.data.first().blocktankRefundAddress
if (cached == null) return@withLock allocateBlocktankRefundAddress()

if (cached.index !in 0..Int.MAX_VALUE.toLong()) {
throw AppError("Invalid cached Blocktank refund address index")
}
if (cached.address.isBlank()) {
throw AppError("Invalid cached Blocktank refund address")
}

val index = cached.index.toInt()
val derived = lightningRepo.addressInfoForType(AddressType.P2WPKH, index).getOrThrow()
if (derived.index != index || derived.address != cached.address) {
throw AppError("Cached Blocktank refund address does not belong to the active wallet")
}

lightningRepo.revealReceiveAddresses(index, AddressType.P2WPKH).getOrThrow()
if (!coreService.isAddressUsed(cached.address)) return@withLock cached.address

allocateBlocktankRefundAddress()
}

private suspend fun allocateBlocktankRefundAddress(): String {
val derived = lightningRepo.newAddressInfoForType(AddressType.P2WPKH).getOrThrow()
if (derived.index !in 0..Int.MAX_VALUE || derived.address.isBlank()) {
throw AppError("Failed to allocate a valid Blocktank refund address")
}

cacheStore.update {
it.copy(
blocktankRefundAddress = BlocktankRefundAddress(
address = derived.address,
index = derived.index.toLong(),
),
)
}
return derived.address
}

suspend fun estimateOrderFee(
spendingBalanceSats: ULong,
receivingBalanceSats: ULong,
Expand Down
32 changes: 18 additions & 14 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import to.bitkit.ext.toPeerDetailsList
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
import to.bitkit.models.CoinSelectionPreference
import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING
import to.bitkit.models.ElectrumServer
import to.bitkit.models.NATIVE_WITNESS_TYPES
import to.bitkit.models.NodeLifecycleState
Expand Down Expand Up @@ -947,14 +948,15 @@ class LightningRepo @Inject constructor(
val previousSettings = settingsStore.data.first()
val oldSelected = previousSettings.selectedAddressType
val oldMonitored = previousSettings.addressTypesToMonitor
val requiredMonitoredTypes = (monitoredTypes + DEFAULT_ADDRESS_TYPE_STRING).distinct()
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)
it.copy(selectedAddressType = selectedType, addressTypesToMonitor = requiredMonitoredTypes)
}
lightningService.setPrimaryAddressType(addressType)
syncMonitoredTypesFromNode()
Expand Down Expand Up @@ -1011,29 +1013,30 @@ class LightningRepo @Inject constructor(
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(
val configurationError = when {
addressType == AddressType.P2WPKH ->
AppError("Cannot disable monitoring: Native SegWit is required for Blocktank refunds")
addressType == settings.selectedAddressType.toAddressType() ->
AppError("Cannot disable monitoring: address type is currently selected")
isLastRequiredNativeWitnessWallet(addressType, monitoredTypes) -> AppError(
"Cannot disable monitoring: at least one Native SegWit or Taproot wallet required for Lightning"
)
else -> null
}
if (configurationError != null) return configurationError

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
return if (balance > 0uL) AppError("Cannot disable monitoring: address type has balance") else null
}

private suspend fun syncMonitoredTypesFromNode() {
runCatching {
val nodeMonitored = lightningService.listMonitoredAddressTypes()
val settings = settingsStore.data.first()
val selectedType = settings.selectedAddressType.toAddressType() ?: AddressType.P2WPKH
val combined = (nodeMonitored + selectedType).distinct()
val combined = (nodeMonitored + selectedType + AddressType.P2WPKH).distinct()
val allOrdered = ALL_ADDRESS_TYPE_STRINGS
val newMonitored = allOrdered.filter { typeStr ->
typeStr.toAddressType() in combined
Expand All @@ -1054,6 +1057,7 @@ class LightningRepo @Inject constructor(
val monitored = settings.addressTypesToMonitor.toMutableList()

val toRemove = monitored.filter { typeStr ->
if (typeStr == DEFAULT_ADDRESS_TYPE_STRING) return@filter false
if (typeStr == settings.selectedAddressType) return@filter false
val type = typeStr.toAddressType() ?: return@filter false
val balance = getBalanceForAddressType(type).getOrNull() ?: return@filter false
Expand Down Expand Up @@ -1149,12 +1153,12 @@ class LightningRepo @Inject constructor(

suspend fun newAddressInfoForType(addressType: AddressType): Result<AddressDerivationInfo> =
executeWhenNodeRunning("newAddressInfoForType") {
runCatching { lightningService.newAddressInfoForType(addressType) }
runSuspendCatching { lightningService.newAddressInfoForType(addressType) }
}

suspend fun addressInfoForType(addressType: AddressType, receiveIndex: Int): Result<AddressDerivationInfo> =
executeWhenNodeRunning("addressInfoForType") {
runCatching { lightningService.addressInfoForType(addressType, receiveIndex) }
runSuspendCatching { lightningService.addressInfoForType(addressType, receiveIndex) }
}

suspend fun addressInfosForType(
Expand All @@ -1169,7 +1173,7 @@ class LightningRepo @Inject constructor(

suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType): Result<Unit> =
executeWhenNodeRunning("revealReceiveAddresses") {
runCatching { lightningService.revealReceiveAddresses(toReceiveIndex, forType) }
runSuspendCatching { lightningService.revealReceiveAddresses(toReceiveIndex, forType) }
}

suspend fun createInvoice(
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import to.bitkit.data.SettingsStore
import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.backup.VssStoreIdProvider
import to.bitkit.data.keychain.Keychain
import to.bitkit.data.withRequiredNativeSegwitMonitoring
import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Defaults
Expand Down Expand Up @@ -244,7 +245,11 @@ class LightningService @Inject constructor(
config: Config,
channelMigration: ChannelDataMigration? = null,
): Node = ServiceQueue.LDK.background {
val settings = settingsStore.data.first()
val storedSettings = settingsStore.data.first()
val settings = storedSettings.withRequiredNativeSegwitMonitoring()
if (settings != storedSettings) {
settingsStore.update { it.withRequiredNativeSegwitMonitoring() }
}
val selectedType = settings.selectedAddressType.toAddressType()?.toLdkAddressType()
?: LdkAddressType.NATIVE_SEGWIT
val monitoredTypes = settings.addressTypesToMonitor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.R
import to.bitkit.data.SettingsStore
import to.bitkit.data.withRequiredNativeSegwitMonitoring
import to.bitkit.di.BgDispatcher
import to.bitkit.models.DEFAULT_ADDRESS_TYPE
import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING
Expand Down Expand Up @@ -50,7 +51,7 @@ class AddressTypePreferenceViewModel @Inject constructor(

private fun loadState() {
viewModelScope.launch(bgDispatcher) {
settingsStore.data.first().let { settings ->
settingsStore.data.first().withRequiredNativeSegwitMonitoring().let { settings ->
val selected = settings.selectedAddressType.toAddressType() ?: AddressType.P2WPKH
val monitored = settings.addressTypesToMonitor.toImmutableSet()
_uiState.update {
Expand Down Expand Up @@ -147,6 +148,8 @@ class AddressTypePreferenceViewModel @Inject constructor(
}

private fun monitoringErrorMessage(errorMessage: String?): String? = when {
errorMessage?.contains("Blocktank refunds") == true ->
context.getString(R.string.settings__addr_type__disabled_native_refund_required)
errorMessage?.contains("has balance") == true ->
context.getString(R.string.settings__addr_type__disabled_has_balance)
errorMessage?.contains("verify") == true ->
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
<string name="settings__addr_type__changed">Address Type Changed</string>
<string name="settings__addr_type__disabled_currently_selected">Cannot disable monitoring: address type is currently selected</string>
<string name="settings__addr_type__disabled_has_balance">Cannot disable monitoring: address type has balance</string>
<string name="settings__addr_type__disabled_native_refund_required">Native SegWit monitoring is required to receive Blocktank refunds.</string>
<string name="settings__addr_type__disabled_native_required">At least one Native SegWit or Taproot wallet is required for Lightning channels.</string>
<string name="settings__addr_type__disabled_verify_failed">Cannot disable monitoring: failed to verify balance</string>
<string name="settings__addr_type__monitoring">Monitor address types</string>
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/java/to/bitkit/data/AppCacheDataTest.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package to.bitkit.data

import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Test
import to.bitkit.data.serializers.AppCacheSerializer
import to.bitkit.di.json
import to.bitkit.ext.scopedActivityId
import to.bitkit.models.BalanceState
import to.bitkit.models.WalletScope
import to.bitkit.test.BaseUnitTest
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class AppCacheDataTest : BaseUnitTest() {
Expand All @@ -29,6 +33,26 @@ class AppCacheDataTest : BaseUnitTest() {

assertEquals(LEGACY_BOLT11, cachedReceive.bolt11)
assertEquals("", cachedReceive.bolt11PaymentHash)
assertNull(cachedReceive.blocktankRefundAddress)
}

@Test
fun `Blocktank refund address uses the shared cross-platform cache shape`() {
val cache = AppCacheData(
blocktankRefundAddress = BlocktankRefundAddress(
address = "bcrt1qrefund",
index = 7,
),
)

val encoded = json.encodeToString(cache)
val refund = json.parseToJsonElement(encoded).jsonObject
.getValue("blocktankRefundAddress").jsonObject
val decoded = json.decodeFromString<AppCacheData>(encoded)

assertEquals("bcrt1qrefund", refund.getValue("address").jsonPrimitive.content)
assertEquals("7", refund.getValue("index").jsonPrimitive.content)
assertEquals(cache.blocktankRefundAddress, decoded.blocktankRefundAddress)
}

@Test
Expand Down
16 changes: 16 additions & 0 deletions app/src/test/java/to/bitkit/data/SettingsDataTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package to.bitkit.data

import org.junit.Test
import kotlin.test.assertEquals

class SettingsDataTest {
@Test
fun `native SegWit monitoring is added to Taproot-only settings`() {
val updated = SettingsData(
selectedAddressType = "taproot",
addressTypesToMonitor = listOf("taproot"),
).withRequiredNativeSegwitMonitoring()

assertEquals(listOf("taproot", "nativeSegwit"), updated.addressTypesToMonitor)
}
}
Loading
Loading