Skip to content

Commit 455eb68

Browse files
committed
fix: track pending hw transfers
1 parent 79c970a commit 455eb68

8 files changed

Lines changed: 182 additions & 15 deletions

File tree

app/src/main/java/to/bitkit/data/dao/TransferDao.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
1010
import to.bitkit.data.entities.TransferEntity
1111

1212
@Dao
13+
@Suppress("TooManyFunctions")
1314
interface TransferDao {
1415
@Insert(onConflict = OnConflictStrategy.REPLACE)
1516
suspend fun insert(transfer: TransferEntity)
@@ -35,6 +36,9 @@ interface TransferDao {
3536
@Query("SELECT * FROM transfers WHERE id = :id LIMIT 1")
3637
suspend fun getById(id: String): TransferEntity?
3738

39+
@Query("SELECT * FROM transfers WHERE fundingTxId = :fundingTxId LIMIT 1")
40+
suspend fun getByFundingTxId(fundingTxId: String): TransferEntity?
41+
3842
@Query("UPDATE transfers SET isSettled = 1, settledAt = :settledAt WHERE id = :id")
3943
suspend fun markSettled(id: String, settledAt: Long)
4044

app/src/main/java/to/bitkit/repositories/TransferRepo.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package to.bitkit.repositories
33
import com.synonym.bitkitcore.Activity
44
import com.synonym.bitkitcore.ActivityFilter
55
import com.synonym.bitkitcore.BtOrderState2
6+
import com.synonym.bitkitcore.IBtOrder
67
import com.synonym.bitkitcore.SortDirection
78
import kotlinx.coroutines.CoroutineDispatcher
89
import kotlinx.coroutines.flow.Flow
@@ -16,6 +17,7 @@ import to.bitkit.data.entities.TransferEntity
1617
import to.bitkit.di.BgDispatcher
1718
import to.bitkit.ext.channelId
1819
import to.bitkit.ext.latestSpendingTxid
20+
import to.bitkit.ext.runSuspendCatching
1921
import to.bitkit.models.TransferType
2022
import to.bitkit.services.CoreService
2123
import to.bitkit.utils.BlockTimeHelpers
@@ -94,6 +96,39 @@ class TransferRepo @Inject constructor(
9496
}
9597
}
9698

99+
@Suppress("LongParameterList")
100+
suspend fun createPendingToSpendingActivity(
101+
order: IBtOrder,
102+
txId: String,
103+
fee: ULong,
104+
feeRate: ULong,
105+
): Result<Unit> = withContext(bgDispatcher) {
106+
runSuspendCatching {
107+
val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) {
108+
"Order '${order.id}' has no on-chain payment address"
109+
}
110+
coreService.activity.createSentOnchainActivityFromSendResult(
111+
txid = txId,
112+
address = address,
113+
amount = order.feeSat,
114+
fee = fee,
115+
feeRate = feeRate,
116+
isTransfer = true,
117+
channelId = order.channel?.shortChannelId,
118+
)
119+
}.onFailure {
120+
Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG)
121+
}
122+
}
123+
124+
suspend fun findLspOrderIdByFundingTxId(fundingTxId: String): Result<String?> = withContext(bgDispatcher) {
125+
runSuspendCatching {
126+
transferDao.getByFundingTxId(fundingTxId)?.lspOrderId
127+
}.onFailure {
128+
Logger.warn("Failed to find transfer by funding txid '$fundingTxId'", it, context = TAG)
129+
}
130+
}
131+
97132
suspend fun syncTransferStates(): Result<Unit> = withContext(bgDispatcher) {
98133
runCatching {
99134
val activeTransfers = transferDao.getActiveTransfers().first()

app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class DeriveBalanceStateUseCase @Inject constructor(
3737
val channels = lightningRepo.getChannels().orEmpty()
3838
val activeTransfers = transferRepo.activeTransfers.first()
3939

40-
val paidOrdersSats = getOrderPaymentsSats(activeTransfers)
40+
val paidOrdersSats = getOrderPaymentsSats(activeTransfers, channels, balanceDetails)
4141
val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails)
4242

4343
val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails)
@@ -69,25 +69,41 @@ class DeriveBalanceStateUseCase @Inject constructor(
6969
}
7070
}
7171

72-
private fun getOrderPaymentsSats(transfers: List<TransferEntity>): ULong {
73-
return transfers
74-
.filter { it.type.isToSpending() && it.lspOrderId != null }
75-
.sumOf { it.amountSats.toULong() }
72+
private suspend fun getOrderPaymentsSats(
73+
transfers: List<TransferEntity>,
74+
channels: List<ChannelDetails>,
75+
balances: BalanceDetails,
76+
): ULong {
77+
var amount = 0uL
78+
val paidOrders = transfers.filter { it.type.isToSpending() && it.lspOrderId != null }
79+
80+
for (transfer in paidOrders) {
81+
val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels)
82+
val channelBalance = channelId?.let { id ->
83+
balances.lightningBalances.find { it.channelId() == id }
84+
}
85+
if (channelBalance == null) {
86+
amount = amount.safe() + transfer.amountSats.toULong().safe()
87+
}
88+
}
89+
90+
return amount
7691
}
7792

78-
private fun getPendingChannelsSats(
93+
private suspend fun getPendingChannelsSats(
7994
transfers: List<TransferEntity>,
8095
channels: List<ChannelDetails>,
8196
balances: BalanceDetails,
8297
): ULong {
8398
var amount = 0uL
84-
val pendingTransfers = transfers.filter { it.type.isToSpending() && it.channelId != null }
99+
val pendingTransfers = transfers.filter { it.type.isToSpending() }
85100

86101
for (transfer in pendingTransfers) {
87-
val channel = channels.find { it.channelId == transfer.channelId }
102+
val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels)
103+
val channel = channels.find { it.channelId == channelId }
88104
if (channel != null && !channel.isChannelReady) {
89105
val channelBalance = balances.lightningBalances.find { it.channelId() == channel.channelId }
90-
amount += channelBalance?.amountSats() ?: 0u
106+
amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe()
91107
}
92108
}
93109

@@ -105,7 +121,7 @@ class DeriveBalanceStateUseCase @Inject constructor(
105121
for (transfer in toSavings) {
106122
val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels)
107123
val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId }
108-
toSavingsAmount += channelBalance?.amountSats() ?: 0u
124+
toSavingsAmount = toSavingsAmount.safe() + (channelBalance?.amountSats() ?: 0uL).safe()
109125
}
110126

111127
return toSavingsAmount
@@ -134,7 +150,7 @@ class DeriveBalanceStateUseCase @Inject constructor(
134150
for (transfer in transfers.filter { it.type == TransferType.COOP_CLOSE }) {
135151
val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels)
136152
val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId }
137-
amount += channelBalance?.amountSats() ?: 0u
153+
amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe()
138154
}
139155
return amount
140156
}

app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ import to.bitkit.ext.rawId
2828
import to.bitkit.repositories.ActivityRepo
2929
import to.bitkit.repositories.BlocktankRepo
3030
import to.bitkit.repositories.HwWalletRepo
31+
import to.bitkit.repositories.TransferRepo
3132
import to.bitkit.utils.Logger
3233
import javax.inject.Inject
3334

34-
@Suppress("TooManyFunctions")
35+
@Suppress("LongParameterList", "TooManyFunctions")
3536
@HiltViewModel
3637
class ActivityDetailViewModel @Inject constructor(
3738
@ApplicationContext private val context: Context,
@@ -40,6 +41,7 @@ class ActivityDetailViewModel @Inject constructor(
4041
private val settingsStore: SettingsStore,
4142
private val blocktankRepo: BlocktankRepo,
4243
private val hwWalletRepo: HwWalletRepo,
44+
private val transferRepo: TransferRepo,
4345
) : ViewModel() {
4446
private val _txDetails = MutableStateFlow<TransactionDetails?>(null)
4547
val txDetails = _txDetails.asStateFlow()
@@ -255,6 +257,14 @@ class ActivityDetailViewModel @Inject constructor(
255257
orders.firstOrNull { order ->
256258
order.payment?.onchain?.transactions?.any { it.txId == txId } == true
257259
}?.let { return@withContext it }
260+
261+
val orderId = transferRepo.findLspOrderIdByFundingTxId(txId).getOrNull()
262+
if (orderId != null) {
263+
orders.find { it.id == orderId }?.let { return@withContext it }
264+
blocktankRepo.getOrder(orderId, refresh = false).getOrNull()?.let {
265+
return@withContext it
266+
}
267+
}
258268
}
259269

260270
null

app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,27 @@ class TransferViewModel @Inject constructor(
236236
}
237237

238238
/** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */
239-
private suspend fun fundPaidOrder(order: IBtOrder, txId: String) {
239+
private suspend fun fundPaidOrder(
240+
order: IBtOrder,
241+
txId: String,
242+
createTransferActivity: Boolean = false,
243+
feeRate: ULong = 0uL,
244+
) {
240245
cacheStore.addPaidOrder(orderId = order.id, txId = txId)
241246
transferRepo.createTransfer(
242247
type = TransferType.TO_SPENDING,
243248
amountSats = order.clientBalanceSat.toLong(),
249+
fundingTxId = txId,
244250
lspOrderId = order.id,
245251
)
252+
if (createTransferActivity) {
253+
transferRepo.createPendingToSpendingActivity(
254+
order = order,
255+
txId = txId,
256+
fee = 0uL,
257+
feeRate = feeRate,
258+
)
259+
}
246260
viewModelScope.launch { walletRepo.syncBalances() }
247261
viewModelScope.launch { watchOrder(order.id) }
248262
}
@@ -497,13 +511,20 @@ class TransferViewModel @Inject constructor(
497511
}
498512
}
499513

514+
val satsPerVByte = hwFundingSatsPerVByte()
515+
500516
hwWalletRepo.signAndBroadcastFunding(
501517
deviceId = deviceId,
502518
address = address,
503519
sats = order.feeSat,
504-
satsPerVByte = hwFundingSatsPerVByte(),
520+
satsPerVByte = satsPerVByte,
505521
).onSuccess { txId ->
506-
fundPaidOrder(order, txId)
522+
fundPaidOrder(
523+
order = order,
524+
txId = txId,
525+
createTransferActivity = true,
526+
feeRate = satsPerVByte,
527+
)
507528
_spendingUiState.update { it.copy(isSigning = false) }
508529
setTransferEffect(TransferEffect.OnHwTxSigned)
509530
}.onFailure { e ->

app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import com.synonym.bitkitcore.PaymentType
88
import kotlinx.collections.immutable.persistentListOf
99
import kotlinx.collections.immutable.toImmutableList
1010
import kotlinx.coroutines.flow.MutableStateFlow
11+
import kotlinx.coroutines.runBlocking
1112
import org.junit.Before
1213
import org.junit.Test
14+
import org.mockito.kotlin.any
1315
import org.mockito.kotlin.doReturn
16+
import org.mockito.kotlin.eq
1417
import org.mockito.kotlin.mock
1518
import org.mockito.kotlin.mockingDetails
1619
import org.mockito.kotlin.whenever
@@ -32,6 +35,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
3235
private val blocktankRepo = mock<BlocktankRepo>()
3336
private val settingsStore = mock<SettingsStore>()
3437
private val hwWalletRepo = mock<HwWalletRepo>()
38+
private val transferRepo = mock<TransferRepo>()
3539

3640
companion object Fixtures {
3741
const val ACTIVITY_ID = "test-activity-1"
@@ -45,6 +49,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
4549
whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState()))
4650
whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis()))
4751
whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf()))
52+
runBlocking {
53+
whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null))
54+
}
4855

4956
sut = ActivityDetailViewModel(
5057
context = context,
@@ -53,6 +60,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
5360
blocktankRepo = blocktankRepo,
5461
settingsStore = settingsStore,
5562
hwWalletRepo = hwWalletRepo,
63+
transferRepo = transferRepo,
5664
)
5765
}
5866

@@ -153,6 +161,19 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
153161
assertNull(result)
154162
}
155163

164+
@Test
165+
fun `findOrderForTransfer finds pending order by transfer funding txId`() = test {
166+
val txId = "funding-tx-id"
167+
val order = mock<IBtOrder> { on { id } doReturn ORDER_ID }
168+
whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(orders = persistentListOf())))
169+
whenever(transferRepo.findLspOrderIdByFundingTxId(txId)).thenReturn(Result.success(ORDER_ID))
170+
whenever(blocktankRepo.getOrder(eq(ORDER_ID), eq(false))).thenReturn(Result.success(order))
171+
172+
val result = sut.findOrderForTransfer(null, txId)
173+
174+
assertEquals(order, result)
175+
}
176+
156177
@Test
157178
fun `loadActivity starts observation of activity changes`() = test {
158179
val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false)

app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
4646
whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf()))
4747
wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList()))
4848
wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL)
49+
whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenAnswer { invocation ->
50+
val transfer = invocation.getArgument<TransferEntity>(0)
51+
transfer.channelId
52+
}
4953
wheneverBlocking {
5054
lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull())
5155
}.thenReturn(Result.success(1000uL))
@@ -208,6 +212,46 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
208212
)
209213
}
210214

215+
@Test
216+
fun `should move LSP order transfer to pending channel once LDK reports the balance`() = test {
217+
val channelId = "lsp-pending-channel-id"
218+
val amountSats = 50_000uL
219+
val channelBalance = newChannelBalance(channelId, amountSats)
220+
val balance = newBalanceDetails().copy(
221+
lightningBalances = listOf(channelBalance),
222+
totalLightningBalanceSats = amountSats,
223+
)
224+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))
225+
226+
val channel = mock<ChannelDetails> {
227+
on { this.channelId } doReturn channelId
228+
on { isChannelReady } doReturn false
229+
}
230+
val transfers = listOf(
231+
newTransferEntity(
232+
type = TransferType.TO_SPENDING,
233+
amountSats = amountSats.toLong(),
234+
channelId = null,
235+
lspOrderId = "lsp-order-id",
236+
)
237+
)
238+
239+
whenever(lightningRepo.getChannels()).thenReturn(listOf(channel))
240+
whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers))
241+
whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenReturn(channelId)
242+
243+
val result = sut()
244+
245+
assertTrue(result.isSuccess)
246+
val balanceState = result.getOrThrow()
247+
assertEquals(amountSats, balanceState.balanceInTransferToSpending)
248+
assertEquals(
249+
0uL,
250+
balanceState.totalLightningSats,
251+
"Lightning balance reduced while the discovered LSP channel is still pending"
252+
)
253+
}
254+
211255
@Test
212256
fun `should not count manual channel as pending when ready`() = test {
213257
newBalanceDetails()

app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import org.lightningdevkit.ldknode.NodeStatus
1717
import org.mockito.kotlin.any
1818
import org.mockito.kotlin.anyOrNull
1919
import org.mockito.kotlin.eq
20+
import org.mockito.kotlin.isNull
2021
import org.mockito.kotlin.mock
2122
import org.mockito.kotlin.never
2223
import org.mockito.kotlin.verify
@@ -26,6 +27,7 @@ import to.bitkit.data.SettingsData
2627
import to.bitkit.data.SettingsStore
2728
import to.bitkit.models.BalanceState
2829
import to.bitkit.models.HwWallet
30+
import to.bitkit.models.TransferType
2931
import to.bitkit.models.TransportType
3032
import to.bitkit.repositories.BlocktankRepo
3133
import to.bitkit.repositories.BlocktankState
@@ -173,6 +175,20 @@ class TransferViewModelTest : BaseUnitTest() {
173175
eq(FEE_RATE),
174176
)
175177
verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID))
178+
verify(transferRepo).createTransfer(
179+
eq(TransferType.TO_SPENDING),
180+
eq(order.clientBalanceSat.toLong()),
181+
isNull<String>(),
182+
eq(TXID),
183+
eq(order.id),
184+
isNull<UInt>(),
185+
)
186+
verify(transferRepo).createPendingToSpendingActivity(
187+
eq(order),
188+
eq(TXID),
189+
eq(0uL),
190+
eq(FEE_RATE),
191+
)
176192
verify(hwWalletRepo, never()).reconnect(any())
177193
}
178194

0 commit comments

Comments
 (0)