diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 894f588dbd..f45f5e3cdd 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -166,6 +166,7 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPayLedger: QuickPayLedger? = null, + val blocktankRefundAddress: BlocktankRefundAddress? = null, ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || @@ -177,3 +178,9 @@ data class AppCacheData( fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") } + +@Serializable +data class BlocktankRefundAddress( + val address: String, + val index: Long, +) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index eddec111d1..8c8d1e1046 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -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 @@ -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) { @@ -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 || diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 5a30fdb06d..2ebbf46c93 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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.asStateFlow() @@ -312,16 +319,25 @@ class BlocktankRepo @Inject constructor( receivingBalanceSats: ULong = spendingBalanceSats * 2u, channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, ): Result = 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, ) @@ -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, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 406e16127b..ad386322a9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -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 @@ -947,6 +948,7 @@ 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() = @@ -954,7 +956,7 @@ class LightningRepo @Inject constructor( runCatching { settingsStore.update { - it.copy(selectedAddressType = selectedType, addressTypesToMonitor = monitoredTypes) + it.copy(selectedAddressType = selectedType, addressTypesToMonitor = requiredMonitoredTypes) } lightningService.setPrimaryAddressType(addressType) syncMonitoredTypesFromNode() @@ -1011,21 +1013,22 @@ class LightningRepo @Inject constructor( 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( + 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() { @@ -1033,7 +1036,7 @@ class LightningRepo @Inject constructor( 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 @@ -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 @@ -1149,12 +1153,12 @@ class LightningRepo @Inject constructor( suspend fun newAddressInfoForType(addressType: AddressType): Result = executeWhenNodeRunning("newAddressInfoForType") { - runCatching { lightningService.newAddressInfoForType(addressType) } + runSuspendCatching { lightningService.newAddressInfoForType(addressType) } } suspend fun addressInfoForType(addressType: AddressType, receiveIndex: Int): Result = executeWhenNodeRunning("addressInfoForType") { - runCatching { lightningService.addressInfoForType(addressType, receiveIndex) } + runSuspendCatching { lightningService.addressInfoForType(addressType, receiveIndex) } } suspend fun addressInfosForType( @@ -1169,7 +1173,7 @@ class LightningRepo @Inject constructor( suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType): Result = executeWhenNodeRunning("revealReceiveAddresses") { - runCatching { lightningService.revealReceiveAddresses(toReceiveIndex, forType) } + runSuspendCatching { lightningService.revealReceiveAddresses(toReceiveIndex, forType) } } suspend fun createInvoice( diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 4e95520c70..bc887e9cd1 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -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 @@ -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 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 index 7f4a9d5212..69a33290ff 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt @@ -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 @@ -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 { @@ -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 -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed794ef4ec..ffd12a5efe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -808,6 +808,7 @@ Address Type Changed Cannot disable monitoring: address type is currently selected Cannot disable monitoring: address type has balance + Native SegWit monitoring is required to receive Blocktank refunds. At least one Native SegWit or Taproot wallet is required for Lightning channels. Cannot disable monitoring: failed to verify balance Monitor address types diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index 414c572fee..2401dfcb86 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -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() { @@ -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(encoded) + + assertEquals("bcrt1qrefund", refund.getValue("address").jsonPrimitive.content) + assertEquals("7", refund.getValue("index").jsonPrimitive.content) + assertEquals(cache.blocktankRefundAddress, decoded.blocktankRefundAddress) } @Test diff --git a/app/src/test/java/to/bitkit/data/SettingsDataTest.kt b/app/src/test/java/to/bitkit/data/SettingsDataTest.kt new file mode 100644 index 0000000000..55d22d9f38 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/SettingsDataTest.kt @@ -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) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index b8a88abf98..51ffa91440 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -28,6 +28,7 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.AppCacheData import to.bitkit.data.AppDb +import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.data.HwWalletData import to.bitkit.data.HwWalletStore @@ -470,6 +471,34 @@ class BackupRepoTest : BaseUnitTest() { assertEquals(listOf(preActivityMetadata(), hardwareTagMetadata), payload.tagMetadata) } + @Test + fun `metadata backup carries the Blocktank refund address pointer`() = test { + val refundAddress = BlocktankRefundAddress(address = "bcrt1qrefund", index = 7) + cacheData.value = AppCacheData(blocktankRefundAddress = refundAddress) + stubMetadataBackupReads() + val dataCaptor = argumentCaptor() + + sut.triggerBackup(BackupCategory.METADATA) + + verifyBlocking(vssBackupClient) { + putObject(eq(BackupCategory.METADATA.name), dataCaptor.capture()) + } + val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) + assertEquals(refundAddress, payload.cache.blocktankRefundAddress) + } + + @Test + fun `metadata restore preserves the Blocktank refund address pointer`() = test { + val refundAddress = BlocktankRefundAddress(address = "bcrt1qrefund", index = 7) + stubMetadataRestore(envelope = metadataEnvelope(cache = AppCacheData(blocktankRefundAddress = refundAddress))) + val cacheTransform = argumentCaptor<(AppCacheData) -> AppCacheData>() + + sut.performFullRestoreFromLatestBackup() + + verifyBlocking(cacheStore) { update(cacheTransform.capture()) } + assertEquals(refundAddress, cacheTransform.firstValue(AppCacheData()).blocktankRefundAddress) + } + @Test fun `metadata backup carries the hardware wallet names`() = test { stubMetadataBackupReads() @@ -772,12 +801,13 @@ class BackupRepoTest : BaseUnitTest() { private fun metadataEnvelope( metadata: List = emptyList(), + cache: AppCacheData = AppCacheData(), hwWalletNames: Map? = null, ) = json.encodeToString( MetadataBackupV1( createdAt = 123, tagMetadata = metadata, - cache = AppCacheData(), + cache = cache, hwWalletNames = hwWalletNames, ) ) diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 909a8b7d4b..d271990bbc 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -1,33 +1,49 @@ package to.bitkit.repositories import app.cash.turbine.test +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.CJitStateEnum +import com.synonym.bitkitcore.CreateOrderOptions import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel +import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.OutPoint +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer 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 org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData +import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.models.BlocktankBackupV1 +import to.bitkit.services.AddressDerivationInfo +import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.ServiceError import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -35,10 +51,12 @@ import kotlin.test.assertTrue class BlocktankRepoTest : BaseUnitTest() { private val coreService: CoreService = mock() + private val blocktankService: BlocktankService = mock() private val lightningService: LightningService = mock() private val currencyRepo: CurrencyRepo = mock() private val cacheStore: CacheStore = mock() private val lightningRepo: LightningRepo = mock() + private val cacheData = MutableStateFlow(AppCacheData()) private lateinit var sut: BlocktankRepo @@ -46,18 +64,28 @@ class BlocktankRepoTest : BaseUnitTest() { @Before fun setUp() { - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + cacheData.value = AppCacheData() + whenever(cacheStore.data).thenReturn(cacheData) + whenever { cacheStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0) + cacheData.value = transform(cacheData.value) + } whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) - whenever(coreService.blocktank).thenReturn(mock()) + whenever(coreService.blocktank).thenReturn(blocktankService) + whenever { coreService.isGeoBlocked() }.thenReturn(false) + whenever { coreService.isAddressUsed(any()) }.thenReturn(false) + whenever(lightningService.nodeId).thenReturn("node-id") + whenever { lightningService.sign(any()) }.thenReturn("signature") - wheneverBlocking { coreService.blocktank.info(refresh = false) }.thenReturn(mock()) - wheneverBlocking { coreService.blocktank.info(refresh = true) }.thenReturn(mock()) + whenever { blocktankService.info(refresh = false) }.thenReturn(mock()) + whenever { blocktankService.info(refresh = true) }.thenReturn(mock()) - wheneverBlocking { coreService.blocktank.orders(refresh = false) }.thenReturn(emptyList()) - wheneverBlocking { coreService.blocktank.orders(refresh = true) }.thenReturn(emptyList()) + whenever { blocktankService.orders(refresh = false) }.thenReturn(emptyList()) + whenever { blocktankService.orders(refresh = true) }.thenReturn(emptyList()) - wheneverBlocking { coreService.blocktank.cjitEntries(refresh = false) }.thenReturn(emptyList()) - wheneverBlocking { coreService.blocktank.cjitEntries(refresh = true) }.thenReturn(emptyList()) + whenever { blocktankService.cjitEntries(refresh = false) }.thenReturn(emptyList()) + whenever { blocktankService.cjitEntries(refresh = true) }.thenReturn(emptyList()) + whenever { lightningRepo.revealReceiveAddresses(any(), any()) }.thenReturn(Result.success(Unit)) } private fun createSut(): BlocktankRepo { @@ -72,6 +100,222 @@ class BlocktankRepoTest : BaseUnitTest() { ) } + @Test + fun `createOrder sends a persisted native SegWit refund address`() = test { + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + val order = mock() + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) + whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) + sut = createSut() + + val result = sut.createOrder(spendingBalanceSats = 50_000u, receivingBalanceSats = 100_000u) + + assertEquals(order, result.getOrThrow()) + assertEquals(BlocktankRefundAddress(addressInfo.address, 0), cacheData.value.blocktankRefundAddress) + val options = argumentCaptor() + verify(blocktankService).newOrder(any(), any(), options.capture()) + assertEquals(addressInfo.address, options.firstValue.refundOnchainAddress) + verify(lightningRepo).newAddressInfoForType(AddressType.P2WPKH) + } + + @Test + fun `estimateOrderFee never allocates a refund address`() = test { + val estimate = mock() + whenever(blocktankService.estimateFee(any(), any(), any())).thenReturn(estimate) + sut = createSut() + + repeat(10) { + assertEquals( + estimate, + sut.estimateOrderFee(spendingBalanceSats = 50_000u, receivingBalanceSats = 100_000u) + .getOrThrow(), + ) + } + + verify(lightningRepo, never()).newAddressInfoForType(any()) + verify(lightningRepo, never()).addressInfoForType(any(), any()) + verify(lightningRepo, never()).revealReceiveAddresses(any(), any()) + assertNull(cacheData.value.blocktankRefundAddress) + } + + @Test + fun `failed and repeated orders reuse one persisted refund address across repo instances`() = test { + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + val order = mock() + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) + whenever(blocktankService.newOrder(any(), any(), any())) + .thenThrow(RuntimeException("backend unavailable")) + .thenReturn(order) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + sut = createSut() + repeat(3) { assertEquals(order, sut.createOrder(50_000u).getOrThrow()) } + + verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) + verify(lightningRepo, times(3)).addressInfoForType(AddressType.P2WPKH, 0) + verify(lightningRepo, times(3)).revealReceiveAddresses(0, AddressType.P2WPKH) + } + + @Test + fun `recorded refund payment rotates the address exactly once`() = test { + val oldInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + val newInfo = AddressDerivationInfo(address = "bcrt1qrefund1", index = 1) + val order = mock() + cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(oldInfo.address, 0)) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(oldInfo)) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 1)).thenReturn(Result.success(newInfo)) + whenever(coreService.isAddressUsed(oldInfo.address)).thenReturn(true) + whenever(coreService.isAddressUsed(newInfo.address)).thenReturn(false) + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(newInfo)) + whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) + sut = createSut() + + assertEquals(order, sut.createOrder(50_000u).getOrThrow()) + assertEquals(order, sut.createOrder(50_000u).getOrThrow()) + + verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) + assertEquals(BlocktankRefundAddress(newInfo.address, 1), cacheData.value.blocktankRefundAddress) + val options = argumentCaptor() + verify(blocktankService, times(2)).newOrder(any(), any(), options.capture()) + assertEquals(listOf(newInfo.address, newInfo.address), options.allValues.map { it.refundOnchainAddress }) + } + + @Test + fun `concurrent orders allocate one shared refund address`() = test { + val allocationStarted = CompletableDeferred() + val allowAllocation = CompletableDeferred() + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + val order = mock() + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).doSuspendableAnswer { + allocationStarted.complete(Unit) + allowAllocation.await() + Result.success(addressInfo) + } + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) + whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) + sut = createSut() + + val first = async { sut.createOrder(50_000u) } + allocationStarted.await() + val second = async { sut.createOrder(50_000u) } + allowAllocation.complete(Unit) + + assertEquals(order, first.await().getOrThrow()) + assertEquals(order, second.await().getOrThrow()) + verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) + } + + @Test + fun `refund address allocation failure blocks order submission`() = test { + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)) + .thenReturn(Result.failure(RuntimeException("node persistence failed"))) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `refund address allocation cancellation blocks order submission and propagates`() = test { + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).doSuspendableAnswer { + throw CancellationException("cancelled") + } + sut = createSut() + + assertFailsWith { sut.createOrder(50_000u) } + + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `cancellation after refund cache persistence blocks order submission`() = test { + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) + whenever { cacheStore.update(any()) }.doSuspendableAnswer { + val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0) + cacheData.value = transform(cacheData.value) + currentCoroutineContext().cancel() + } + sut = createSut() + + val result = async { sut.createOrder(50_000u) } + + assertFailsWith { result.await() } + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `cached refund lookup failure blocks order submission`() = test { + cacheData.value = AppCacheData( + blocktankRefundAddress = BlocktankRefundAddress("bcrt1qrefund0", 0), + ) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)) + .thenReturn(Result.failure(RuntimeException("lookup failed"))) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `cached refund reveal failure blocks order submission`() = test { + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(addressInfo.address, 0)) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) + whenever(lightningRepo.revealReceiveAddresses(0, AddressType.P2WPKH)) + .thenReturn(Result.failure(RuntimeException("reveal failed"))) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `refund cache write failure blocks order submission`() = test { + val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) + whenever { cacheStore.update(any()) }.thenThrow(RuntimeException("cache write failed")) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `invalid cached refund index blocks order submission`() = test { + cacheData.value = AppCacheData( + blocktankRefundAddress = BlocktankRefundAddress("bcrt1qrefund", Int.MAX_VALUE.toLong() + 1), + ) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(lightningRepo, never()).addressInfoForType(any(), any()) + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + + @Test + fun `cached refund ownership mismatch blocks order submission`() = test { + cacheData.value = AppCacheData( + blocktankRefundAddress = BlocktankRefundAddress("bcrt1qwrongwallet", 0), + ) + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn( + Result.success(AddressDerivationInfo(address = "bcrt1qactivewallet", index = 0)), + ) + sut = createSut() + + assertTrue(sut.createOrder(50_000u).isFailure) + + verify(lightningRepo, never()).revealReceiveAddresses(any(), any()) + verify(blocktankService, never()).newOrder(any(), any(), any()) + } + @Test fun `refreshInfo updates state first from cache then server`() = test { sut = createSut() diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 76c9c99ad0..fca4a46d68 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -38,6 +38,7 @@ import org.lightningdevkit.ldknode.TxOutput import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -1434,6 +1435,26 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } + @Test + fun `setMonitoring should fail when disabling native SegWit required for Blocktank refunds`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "taproot", + addressTypesToMonitor = listOf("nativeSegwit", "taproot"), + ) + ) + ) + + val result = sut.setMonitoring(AddressType.P2WPKH, enabled = false) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Blocktank refunds") == true) + verify(lightningService, never()).removeAddressTypeFromMonitor(any()) + verify(lightningService, never()).getBalanceForAddressType(any()) + } + @Test fun `setMonitoring should fail when disabling last required native witness`() = test { startNodeForTesting() @@ -1563,6 +1584,27 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService).setPrimaryAddressType(AddressType.P2TR) } + @Test + fun `updateAddressType retains native SegWit monitoring`() = test { + startNodeForTesting() + val previous = SettingsData( + selectedAddressType = "nativeSegwit", + addressTypesToMonitor = listOf("nativeSegwit"), + ) + whenever(settingsStore.data).thenReturn(flowOf(previous)) + whenever { settingsStore.update(any()) }.thenReturn(Unit) + val transforms = argumentCaptor<(SettingsData) -> SettingsData>() + + val result = sut.updateAddressType("taproot", listOf("taproot")) + + assertTrue(result.isSuccess) + verifyBlocking(settingsStore) { update(transforms.capture()) } + assertEquals( + listOf("taproot", "nativeSegwit"), + transforms.firstValue(previous).addressTypesToMonitor, + ) + } + @Test fun `updateAddressType should fail when setPrimaryAddressType fails`() = test { startNodeForTesting() @@ -1672,6 +1714,24 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService, times(0)).removeAddressTypeFromMonitor(any()) } + @Test + fun `pruneEmptyAddressTypesAfterRestore keeps empty native SegWit when Taproot is selected`() = test { + startNodeForTesting() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + selectedAddressType = "taproot", + addressTypesToMonitor = listOf("nativeSegwit", "taproot"), + ) + ) + ) + + val result = sut.pruneEmptyAddressTypesAfterRestore() + + assertTrue(result.isSuccess) + verify(lightningService, never()).removeAddressTypeFromMonitor(any()) + } + @Test fun `pruneEmptyAddressTypesAfterRestore should skip when address type change in progress`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index e004753765..8908eced59 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -985,11 +985,12 @@ class QuickPayRepoTest : BaseUnitTest() { fun `reconcile during live dispatched op does not steal completion`() = test { val (bolt11, hash) = testInvoice() val dispatched = CompletableDeferred() + val hold = CompletableDeferred>() whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { invocation -> val onBeforeSend = invocation.getArgument Boolean>(2) if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) dispatched.complete(Unit) - Result.success("pid") + hold.await() } val session = QuickPaySession() @@ -1002,6 +1003,7 @@ class QuickPayRepoTest : BaseUnitTest() { sut.reconcileAgainstLdk() assertEquals(250L, spentCents()) assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) + hold.complete(Result.success("pid")) val outcome = sut.signalCompletion(paymentId = "pid", paymentHash = hash, success = true) assertEquals(QuickPayCompletionKind.SETTLED_SUCCESS, outcome.kind) assertTrue(outcome.wasQuickPay) 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 index 47e7573ae3..afe57d77bd 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -43,6 +43,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { private val disabledHasBalance = "Address type has balance" private val disabledVerifyFailed = "Failed to verify balance" private val disabledNativeRequired = "Native SegWit or Taproot required" + private val disabledNativeRefundRequired = "Native SegWit required for Blocktank refunds" private val disabledCurrentlySelected = "Currently selected" @Before @@ -60,6 +61,9 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { whenever( context.getString(R.string.settings__addr_type__disabled_native_required) ).thenReturn(disabledNativeRequired) + whenever( + context.getString(R.string.settings__addr_type__disabled_native_refund_required) + ).thenReturn(disabledNativeRefundRequired) whenever( context.getString(R.string.settings__addr_type__disabled_currently_selected) ).thenReturn(disabledCurrentlySelected) @@ -184,6 +188,33 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { collectJob.cancel() } + @Test + fun `setMonitoring native refund requirement sends localized error toast`() = test { + whenever(lightningRepo.setMonitoring(AddressType.P2WPKH, false)).thenReturn( + Result.failure(Exception("Cannot disable monitoring: Native SegWit is required for Blocktank refunds")), + ) + 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.P2WPKH, false) + advanceUntilIdle() + + assertEquals(Toast.ToastType.WARNING, toasts.last().type) + assertEquals(disabledNativeRefundRequired, toasts.last().description) + collectJob.cancel() + } + @Test fun `updateAddressType success sends success toast`() = test { whenever(lightningRepo.updateAddressType(any(), any())).thenReturn(Result.success(Unit)) diff --git a/changelog.d/next/1235.fixed.md b/changelog.d/next/1235.fixed.md new file mode 100644 index 0000000000..3684d6e4a2 --- /dev/null +++ b/changelog.d/next/1235.fixed.md @@ -0,0 +1 @@ +LSP orders now include a wallet-owned address for on-chain refunds.