Skip to content
Open
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
76 changes: 72 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,63 @@ 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(afterIndex = index)
}

private suspend fun allocateBlocktankRefundAddress(afterIndex: Int? = null): String {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
var previousIndex = afterIndex
repeat(REFUND_ADDRESS_ALLOCATION_LIMIT) {
val derived = lightningRepo.newAddressInfoForType(AddressType.P2WPKH).getOrThrow()
val lastIndex = previousIndex
val hasInvalidIndex = derived.index !in 0..Int.MAX_VALUE
val didNotAdvance = lastIndex != null && derived.index <= lastIndex
if (hasInvalidIndex || derived.address.isBlank() || didNotAdvance) {
throw AppError("Failed to allocate a valid advancing Blocktank refund address")
}
previousIndex = derived.index

if (coreService.isAddressUsed(derived.address)) return@repeat

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

throw AppError("Failed to allocate an unused Blocktank refund address")
}

suspend fun estimateOrderFee(
spendingBalanceSats: ULong,
receivingBalanceSats: ULong,
Expand Down Expand Up @@ -648,6 +715,7 @@ class BlocktankRepo @Inject constructor(

companion object {
private const val TAG = "BlocktankRepo"
private const val REFUND_ADDRESS_ALLOCATION_LIMIT = 20
private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u
private const val DEFAULT_SOURCE = "bitkit-android"
private const val PEER_CONNECTION_DELAY_MS = 2_000L
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
Loading
Loading