diff --git a/app/schemas/to.bitkit.data.AppDb/7.json b/app/schemas/to.bitkit.data.AppDb/7.json new file mode 100644 index 0000000000..042e5f23be --- /dev/null +++ b/app/schemas/to.bitkit.data.AppDb/7.json @@ -0,0 +1,108 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "2b1cda225c170b5f1bbfbf76e5fa4cf5", + "entities": [ + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletIndex` INTEGER NOT NULL, PRIMARY KEY(`walletIndex`))", + "fields": [ + { + "fieldPath": "walletIndex", + "columnName": "walletIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletIndex" + ] + } + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `amountSats` INTEGER NOT NULL, `channelId` TEXT, `fundingTxId` TEXT, `lspOrderId` TEXT, `isSettled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `settledAt` INTEGER, `claimableAtHeight` INTEGER, `txTotalSats` INTEGER, `preTransferOnchainSats` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountSats", + "columnName": "amountSats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "fundingTxId", + "columnName": "fundingTxId", + "affinity": "TEXT" + }, + { + "fieldPath": "lspOrderId", + "columnName": "lspOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "isSettled", + "columnName": "isSettled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settledAt", + "columnName": "settledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "claimableAtHeight", + "columnName": "claimableAtHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "txTotalSats", + "columnName": "txTotalSats", + "affinity": "INTEGER" + }, + { + "fieldPath": "preTransferOnchainSats", + "columnName": "preTransferOnchainSats", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2b1cda225c170b5f1bbfbf76e5fa4cf5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index 81038037aa..f9ce8a2b6b 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -31,7 +31,7 @@ import to.bitkit.env.Env ConfigEntity::class, TransferEntity::class, ], - version = 6, + version = 7, ) @TypeConverters(StringListConverter::class) abstract class AppDb : RoomDatabase() { @@ -45,6 +45,13 @@ abstract class AppDb : RoomDatabase() { } } + private val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE transfers ADD COLUMN txTotalSats INTEGER DEFAULT NULL") + db.execSQL("ALTER TABLE transfers ADD COLUMN preTransferOnchainSats INTEGER DEFAULT NULL") + } + } + private const val DB_NAME = "${BuildConfig.APPLICATION_ID}.sqlite" @Volatile @@ -72,7 +79,7 @@ abstract class AppDb : RoomDatabase() { } } }) - .addMigrations(MIGRATION_5_6) + .addMigrations(MIGRATION_5_6, MIGRATION_6_7) .apply { if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true) } diff --git a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt index 6203f32abe..62b25e512a 100644 --- a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt +++ b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt @@ -18,4 +18,6 @@ data class TransferEntity( val createdAt: Long, val settledAt: Long? = null, val claimableAtHeight: Int? = null, + val txTotalSats: Long? = null, + val preTransferOnchainSats: Long? = null, ) diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 9bfb8b4cd1..7618389b57 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -15,7 +15,14 @@ data class BalanceState( val balanceInTransferToSpending: ULong = 0uL, val hardwareWallets: List = emptyList(), ) { - val totalSats get() = totalOnchainSats + totalLightningSats + val totalSats + get() = totalOnchainSats + .safe() + .plus(totalLightningSats.safe()) + .safe() + .plus(balanceInTransferToSavings.safe()) + .safe() + .plus(balanceInTransferToSpending.safe()) val totalHardwareSats get() = hardwareWallets.fold(0uL) { acc, wallet -> acc.safe() + wallet.sats.safe() } diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 44464bd16c..449a8f56c2 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -62,6 +62,8 @@ class TransferRepo @Inject constructor( fundingTxId: String? = null, lspOrderId: String? = null, claimableAtHeight: UInt? = null, + txTotalSats: Long? = null, + preTransferOnchainSats: Long? = null, ): Result = withContext(bgDispatcher) { runCatching { val id = UUID.randomUUID().toString() @@ -76,6 +78,8 @@ class TransferRepo @Inject constructor( isSettled = false, createdAt = clock.now().epochSeconds, claimableAtHeight = claimableAtHeight?.toInt(), + txTotalSats = txTotalSats, + preTransferOnchainSats = preTransferOnchainSats, ) ) Logger.info("Created transfer: id=$id type=$type channelId=$channelId", context = TAG) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 5aaa4f9e71..6ae7003a66 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -47,8 +47,12 @@ class DeriveBalanceStateUseCase @Inject constructor( val coopCloseSavingsSats = getCoopCloseTransferSats(activeTransfers, channels, balanceDetails) val lingeringCoopCloseSats = getLingeringCoopCloseSats(activeTransfers, channels, balanceDetails) val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() + val orderPaymentsOnchainToSubtract = getOrderPaymentOnchainToSubtract( + activeTransfers = activeTransfers, + currentOnchainSats = balanceDetails.totalOnchainBalanceSats, + ) - val totalOnchainSats = balanceDetails.totalOnchainBalanceSats + val totalOnchainSats = balanceDetails.totalOnchainBalanceSats.safe() - orderPaymentsOnchainToSubtract.safe() val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() @@ -95,6 +99,27 @@ class DeriveBalanceStateUseCase @Inject constructor( return amount } + private fun getOrderPaymentOnchainToSubtract( + activeTransfers: List, + currentOnchainSats: ULong, + ): ULong { + var amount = 0uL + val orderPayments = activeTransfers.filter { + it.type.isToSpending() && it.fundingTxId != null && it.lspOrderId != null + } + + for (transfer in orderPayments) { + val txTotalSats = transfer.txTotalSats?.takeIf { it > 0 }?.toULong() + val preTransferOnchainSats = transfer.preTransferOnchainSats?.takeIf { it > 0 }?.toULong() + + if (txTotalSats != null && preTransferOnchainSats != null && currentOnchainSats >= preTransferOnchainSats) { + amount = amount.safe() + txTotalSats.safe() + } + } + + return amount + } + private suspend fun getPendingChannelsSats( transfers: List, channels: List, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 38116c390e..430d2885f2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -205,8 +205,8 @@ class TransferViewModel @Inject constructor( val address = order.payment?.onchain?.address.orEmpty() // Use live spendableOnchainBalanceSats (not cached) to respect anchor reserves - val spendableBalance = - lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: 0uL + val balanceDetails = lightningRepo.getBalancesAsync().getOrNull() + val spendableBalance = balanceDetails?.spendableOnchainBalanceSats ?: 0uL val sendAllFee = lightningRepo.estimateSendAllFee( address = address, speed = speed, @@ -221,6 +221,24 @@ class TransferViewModel @Inject constructor( val shouldUseSendAll = expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS + val miningFee = if (shouldUseSendAll) { + sendAllFee + } else { + lightningRepo.calculateTotalFee( + amountSats = order.feeSat, + address = address, + speed = speed, + ).getOrElse { + Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) + 0uL + } + } + val txTotalSats = if (shouldUseSendAll) { + spendableBalance + } else { + order.feeSat.safe() + miningFee.safe() + } + Logger.debug( "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + "sendAllFee=$sendAllFee, expectedChange=$expectedChange, sendAll=$shouldUseSendAll", @@ -236,7 +254,14 @@ class TransferViewModel @Inject constructor( channelId = order.channel?.shortChannelId, isMaxAmount = shouldUseSendAll, ) - .onSuccess { txId -> fundPaidOrder(order, txId) } + .onSuccess { txId -> + fundPaidOrder( + order = order, + txId = txId, + txTotalSats = txTotalSats, + preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats ?: spendableBalance, + ) + } .onFailure { error -> ToastEventBus.send(error) } @@ -250,6 +275,8 @@ class TransferViewModel @Inject constructor( createTransferActivity: Boolean = false, fee: ULong = 0uL, feeRate: ULong = 0uL, + txTotalSats: ULong? = null, + preTransferOnchainSats: ULong? = null, ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) transferRepo.createTransfer( @@ -257,6 +284,8 @@ class TransferViewModel @Inject constructor( amountSats = order.clientBalanceSat.toLong(), fundingTxId = txId, lspOrderId = order.id, + txTotalSats = txTotalSats?.toLong(), + preTransferOnchainSats = preTransferOnchainSats?.toLong(), ) if (createTransferActivity) { transferRepo.createPendingToSpendingActivity( diff --git a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt index 808b3b70a8..fdad468ea9 100644 --- a/app/src/test/java/to/bitkit/models/BalanceStateTest.kt +++ b/app/src/test/java/to/bitkit/models/BalanceStateTest.kt @@ -11,6 +11,17 @@ class BalanceStateTest { assertEquals(150uL, state.totalSats) } + @Test + fun `totalSats includes balances in transfer`() { + val state = BalanceState( + totalOnchainSats = 100uL, + totalLightningSats = 50uL, + balanceInTransferToSavings = 10uL, + balanceInTransferToSpending = 15uL, + ) + assertEquals(175uL, state.totalSats) + } + @Test fun `totalHardwareSats sums all hardware wallet balances`() { val state = BalanceState( @@ -27,9 +38,10 @@ class BalanceStateTest { val state = BalanceState( totalOnchainSats = 100uL, totalLightningSats = 50uL, + balanceInTransferToSpending = 15uL, hardwareWallets = listOf(HwWalletBalance(id = "dev1", sats = 25uL)), ) - assertEquals(175uL, state.totalWithHardwareSats) + assertEquals(190uL, state.totalWithHardwareSats) } @Test @@ -46,4 +58,13 @@ class BalanceStateTest { ) assertEquals(ULong.MAX_VALUE, state.totalWithHardwareSats) } + + @Test + fun `totalSats saturates instead of overflowing`() { + val state = BalanceState( + totalOnchainSats = ULong.MAX_VALUE, + balanceInTransferToSpending = 10uL, + ) + assertEquals(ULong.MAX_VALUE, state.totalSats) + } } diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 6174b5c5d0..1e52cb0990 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -95,6 +95,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { balanceState.totalLightningSats, "Lightning balance unchanged - channel not open yet" ) + assertEquals(200_000uL, balanceState.totalSats) } @Test @@ -211,6 +212,40 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { balanceState.totalLightningSats, "Lightning balance reduced - pending channel not ready" ) + assertEquals(150_000uL, balanceState.totalSats) + } + + @Test + fun `should subtract LSP funding transaction while onchain balance has not synced`() = test { + val initialOnchainSats = 86_901uL + val transferSats = 20_212uL + val txTotalSats = 25_809uL + val balance = newBalanceDetails().copy( + totalOnchainBalanceSats = initialOnchainSats, + totalLightningBalanceSats = 0uL, + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + val transfers = listOf( + newTransferEntity( + type = TransferType.TO_SPENDING, + amountSats = transferSats.toLong(), + fundingTxId = "funding-tx-id", + lspOrderId = "lsp-order-id", + txTotalSats = txTotalSats.toLong(), + preTransferOnchainSats = initialOnchainSats.toLong(), + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(61_092uL, balanceState.totalOnchainSats) + assertEquals(transferSats, balanceState.balanceInTransferToSpending) + assertEquals(81_304uL, balanceState.totalSats) } @Test @@ -251,6 +286,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { balanceState.totalLightningSats, "Lightning balance reduced while the discovered LSP channel is still pending" ) + assertEquals(150_000uL, balanceState.totalSats) } @Test @@ -294,6 +330,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { val balanceState = result.getOrThrow() assertEquals(amountSats, balanceState.balanceInTransferToSpending) assertEquals(0uL, balanceState.totalLightningSats) + assertEquals(150_000uL, balanceState.totalSats) } @Test @@ -590,6 +627,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { balanceState.totalLightningSats, "Lightning reduced by manual channel (30k) + transfer to savings (20k)" ) + assertEquals(150_000uL, balanceState.totalSats) } private suspend fun newBalanceDetails() = BalanceDetails( @@ -632,6 +670,8 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { channelId: String? = null, fundingTxId: String? = null, lspOrderId: String? = null, + txTotalSats: Long? = null, + preTransferOnchainSats: Long? = null, ) = TransferEntity( id = "test-transfer-${System.currentTimeMillis()}", type = type, @@ -641,5 +681,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { lspOrderId = lspOrderId, isSettled = false, createdAt = System.currentTimeMillis(), + txTotalSats = txTotalSats, + preTransferOnchainSats = preTransferOnchainSats, ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f6bc83f01e..54f54d56f5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -241,6 +241,8 @@ class TransferViewModelTest : BaseUnitTest() { eq(TXID), eq(order.id), isNull(), + isNull(), + isNull(), ) verify(transferRepo).createPendingToSpendingActivity( eq(order), diff --git a/changelog.d/next/1058.fixed.md b/changelog.d/next/1058.fixed.md new file mode 100644 index 0000000000..2fc484f543 --- /dev/null +++ b/changelog.d/next/1058.fixed.md @@ -0,0 +1 @@ +Pending transfer funds now stay reflected in the total balance without temporarily inflating it while moving between savings and spending.