Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,12 @@ class ActivityRepo @Inject constructor(
transactionDetails: List<BitkitCoreTransactionDetails>,
): Result<List<Activity>> = withContext(bgDispatcher) {
runSuspendCatching {
val transferChannelIds = transferRepo.getChannelIdsByFundingTxId().getOrDefault(emptyMap())
val persistedActivities = coreService.activity.replaceHwSnapshot(
walletId = walletId,
activities = activities,
transactionDetails = transactionDetails,
transferChannelIdsByFundingTxId = transferChannelIds,
)
notifyActivitiesChanged()
persistedActivities
Expand Down
29 changes: 27 additions & 2 deletions app/src/main/java/to/bitkit/repositories/TransferRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ class TransferRepo @Inject constructor(
}
}

// TODO maybe replace with delete, or call delete once activity item was augmented with the transfer's data.
// Likely no clear reason to keep persisting transfers afterwards.
// Settled transfers must be kept: [getChannelIdsByFundingTxId] recovers the transfer flag for a
// hardware wallet's funding tx after the device is removed and re-paired, and removal deletes the
// activities but not these records. Deleting on settle, or wiring up TransferDao.deleteOldSettled,
// regresses synonymdev/bitkit-android#1130.
suspend fun markSettled(id: String): Result<Unit> = withContext(bgDispatcher) {
runCatching {
val settledAt = clock.now().epochSeconds
Expand Down Expand Up @@ -138,6 +140,29 @@ class TransferRepo @Inject constructor(
}
}

/**
* Maps funding tx id to channel id for every transfer Bitkit recorded itself.
*
* Removing a hardware wallet deletes its activities but never these records, so they are what
* lets a re-paired wallet's rediscovered funding tx read as a transfer again instead of a plain
* send. Settled transfers are included on purpose: [syncTransferStates] only re-marks transfers
* that are still active, and a settled one is exactly the case that regresses.
*/
suspend fun getChannelIdsByFundingTxId(): Result<Map<String, String>> = withContext(bgDispatcher) {
runSuspendCatching {
transferDao.getAll()
.mapNotNull { transfer ->
val fundingTxId = transfer.fundingTxId ?: return@mapNotNull null
val channelId = transfer.channelId ?: return@mapNotNull null
fundingTxId to channelId
}
.distinctBy { it.first }
.toMap()
}.onFailure {
Logger.warn("Failed to load channel ids by funding txid", it, context = TAG)
}
}

@Suppress("CyclomaticComplexMethod")
suspend fun syncTransferStates(): Result<Unit> = withContext(bgDispatcher) {
runCatching {
Expand Down
49 changes: 40 additions & 9 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -246,27 +246,49 @@ internal data class HwSnapshotMerge(
val toUpsert: List<Activity>,
)

/**
* Builds the delete/upsert plan for a hardware wallet's on-chain snapshot.
*
* @param transferChannelIdsByFundingTxId funding tx id to channel id for transfers Bitkit recorded
* itself. Re-pairing a wallet that was removed leaves nothing to carry forward, because removal
* deleted its activities, and [to.bitkit.repositories.TransferRepo.syncTransferStates] only re-marks
* transfers that are still unsettled, so a completed transfer would come back from the watcher as a
* plain send. The funding tx id is still recorded against the Bitkit-side transfer, which removal
* does not touch. Has no default so a caller cannot drop the recovery silently.
*/
internal fun mergeHwSnapshot(
existing: List<Activity.Onchain>,
incoming: List<Activity>,
transferChannelIdsByFundingTxId: Map<String, String>,
): HwSnapshotMerge {
val incomingIds = incoming.map { it.rawId() }.toSet()
val toDelete = existing.filter { !it.v1.isTransfer && it.v1.id !in incomingIds }
val existingByTxId = existing.associateBy { it.v1.txId }
val toUpsert = incoming.map { activity ->
val onchain = activity as? Activity.Onchain ?: return@map activity
val stored = existingByTxId[onchain.v1.txId]?.v1 ?: return@map activity
Activity.Onchain(
onchain.v1.copy(
isTransfer = onchain.v1.isTransfer || stored.isTransfer,
channelId = onchain.v1.channelId ?: stored.channelId,
transferTxId = onchain.v1.transferTxId ?: stored.transferTxId,
)
)
val merged = onchain.v1
.mergedWith(existingByTxId[onchain.v1.txId]?.v1)
.withRecoveredTransfer(transferChannelIdsByFundingTxId[onchain.v1.txId])
Activity.Onchain(merged)
}
return HwSnapshotMerge(toDelete = toDelete, toUpsert = toUpsert)
}

private fun OnchainActivity.mergedWith(stored: OnchainActivity?): OnchainActivity = when (stored) {
null -> this
else -> copy(
isTransfer = isTransfer || stored.isTransfer,
channelId = channelId ?: stored.channelId,
transferTxId = transferTxId ?: stored.transferTxId,
)
}

private fun OnchainActivity.withRecoveredTransfer(recoveredChannelId: String?): OnchainActivity =
when {
isTransfer || recoveredChannelId == null -> this
else -> copy(isTransfer = true, channelId = channelId ?: recoveredChannelId)
}

@Suppress("LargeClass", "TooManyFunctions")
class ActivityService(
@Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first
Expand Down Expand Up @@ -321,11 +343,16 @@ class ActivityService(
*
* Callers must merge every watcher for the wallet before invoking this method. The current
* hardware-wallet integration has one supported watcher address type per wallet.
*
* [transferChannelIdsByFundingTxId] carries the Bitkit-side transfer records used to recover
* transfer metadata for transactions that have no stored activity left, e.g. after re-pairing a
* removed device. Pass an empty map when none are known.
*/
suspend fun replaceHwSnapshot(
walletId: String,
activities: List<Activity>,
transactionDetails: List<BitkitCoreTransactionDetails>,
transferChannelIdsByFundingTxId: Map<String, String>,
): List<Activity> = ServiceQueue.CORE.background {
val existingActivities = getActivities(
walletId = walletId,
Expand All @@ -338,7 +365,11 @@ class ActivityService(
limit = null,
sortDirection = null,
).filterIsInstance<Activity.Onchain>()
val merge = mergeHwSnapshot(existing = existingActivities, incoming = activities)
val merge = mergeHwSnapshot(
existing = existingActivities,
incoming = activities,
transferChannelIdsByFundingTxId = transferChannelIdsByFundingTxId,
)
merge.toDelete.forEach {
deleteActivityById(walletId = walletId, activityId = it.v1.id)
deleteTransactionDetails(walletId = walletId, txId = it.v1.txId)
Expand Down
50 changes: 49 additions & 1 deletion app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import to.bitkit.ext.mock
import to.bitkit.models.WalletScope
import to.bitkit.services.CoreService
import to.bitkit.test.BaseUnitTest
import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
Expand Down Expand Up @@ -131,6 +132,7 @@ class ActivityRepoTest : BaseUnitTest() {
whenever(clock.now()).thenReturn(Clock.System.now())
whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState()))
whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState()))
whenever { transferRepo.getChannelIdsByFundingTxId() }.thenReturn(Result.success(emptyMap()))

sut = ActivityRepo(
bgDispatcher = testDispatcher,
Expand Down Expand Up @@ -296,13 +298,59 @@ class ActivityRepoTest : BaseUnitTest() {
walletId = walletId,
activities = listOf(activity),
transactionDetails = emptyList(),
transferChannelIdsByFundingTxId = emptyMap(),
)
).thenReturn(listOf(activity))

val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList())

assertEquals(listOf(activity), result.getOrThrow())
verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList())
verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList(), emptyMap())
}

@Test
fun `persistHwSnapshot forwards known transfer channel ids to the merge`() = test {
val walletId = "hardware-wallet"
val activity = createOnchainActivity().copy(
v1 = baseOnchainActivity.copy(walletId = walletId)
)
val channelIds = mapOf("base_tx_id" to "channel-1")
whenever(transferRepo.getChannelIdsByFundingTxId()).thenReturn(Result.success(channelIds))
whenever(
coreService.activity.replaceHwSnapshot(
walletId = walletId,
activities = listOf(activity),
transactionDetails = emptyList(),
transferChannelIdsByFundingTxId = channelIds,
)
).thenReturn(listOf(activity))

val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList())

assertEquals(listOf(activity), result.getOrThrow())
verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList(), channelIds)
}

@Test
fun `persistHwSnapshot still persists when transfer lookup fails`() = test {
val walletId = "hardware-wallet"
val activity = createOnchainActivity().copy(
v1 = baseOnchainActivity.copy(walletId = walletId)
)
whenever(transferRepo.getChannelIdsByFundingTxId()).thenReturn(Result.failure(AppError("db down")))
whenever(
coreService.activity.replaceHwSnapshot(
walletId = walletId,
activities = listOf(activity),
transactionDetails = emptyList(),
transferChannelIdsByFundingTxId = emptyMap(),
)
).thenReturn(listOf(activity))

val result = sut.persistHwSnapshot(walletId, listOf(activity), emptyList())

assertEquals(listOf(activity), result.getOrThrow())
verify(coreService.activity).replaceHwSnapshot(walletId, listOf(activity), emptyList(), emptyMap())
}

@Test
Expand Down
87 changes: 87 additions & 0 deletions app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,93 @@ class TransferRepoTest : BaseUnitTest() {
assertEquals(exception, result.exceptionOrNull())
}

@Test
fun `markSettled keeps the transfer record for hardware re-pair recovery`() = test {
setupClockNowMock()
whenever(transferDao.markSettled(any(), any())).thenReturn(Unit)

val result = sut.markSettled(ID_TRANSFER)

assertTrue(result.isSuccess)
verify(transferDao).markSettled(eq(ID_TRANSFER), any())
verify(transferDao, never()).deleteOldSettled(any())
}

// MARK: - getChannelIdsByFundingTxId

@Test
fun `getChannelIdsByFundingTxId includes settled transfers`() = test {
whenever(transferDao.getAll()).thenReturn(
listOf(
transferEntity(
id = ID_TRANSFER,
fundingTxId = "funding-tx",
channelId = ID_CHANNEL,
isSettled = true,
)
)
)

val result = sut.getChannelIdsByFundingTxId()

assertEquals(mapOf("funding-tx" to ID_CHANNEL), result.getOrThrow())
}

@Test
fun `getChannelIdsByFundingTxId skips transfers without funding tx id or channel id`() = test {
whenever(transferDao.getAll()).thenReturn(
listOf(
transferEntity(id = "no-funding-tx", fundingTxId = null, channelId = ID_CHANNEL),
transferEntity(id = "no-channel", fundingTxId = "funding-tx", channelId = null),
transferEntity(id = ID_TRANSFER, fundingTxId = "kept-tx", channelId = ID_CHANNEL),
)
)

val result = sut.getChannelIdsByFundingTxId()

assertEquals(mapOf("kept-tx" to ID_CHANNEL), result.getOrThrow())
}

@Test
fun `getChannelIdsByFundingTxId keeps the first entry for duplicated funding tx ids`() = test {
whenever(transferDao.getAll()).thenReturn(
listOf(
transferEntity(id = "first", fundingTxId = "funding-tx", channelId = "channel-1"),
transferEntity(id = "second", fundingTxId = "funding-tx", channelId = "channel-2"),
)
)

val result = sut.getChannelIdsByFundingTxId()

assertEquals(mapOf("funding-tx" to "channel-1"), result.getOrThrow())
}

@Test
fun `getChannelIdsByFundingTxId returns failure when dao throws`() = test {
val exception = AppError("Database error")
whenever(transferDao.getAll()).thenAnswer { throw exception }

val result = sut.getChannelIdsByFundingTxId()

assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}

private fun transferEntity(
id: String,
fundingTxId: String?,
channelId: String?,
isSettled: Boolean = false,
) = TransferEntity(
id = id,
type = TransferType.TO_SPENDING,
amountSats = 50000L,
channelId = channelId,
fundingTxId = fundingTxId,
isSettled = isSettled,
createdAt = 1000L,
)

// MARK: - syncTransferStates

@Test
Expand Down
Loading
Loading