Skip to content
Open
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
108 changes: 108 additions & 0 deletions app/schemas/to.bitkit.data.AppDb/7.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
11 changes: 9 additions & 2 deletions app/src/main/java/to/bitkit/data/AppDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import to.bitkit.env.Env
ConfigEntity::class,
TransferEntity::class,
],
version = 6,
version = 7,
)
@TypeConverters(StringListConverter::class)
abstract class AppDb : RoomDatabase() {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/entities/TransferEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
9 changes: 8 additions & 1 deletion app/src/main/java/to/bitkit/models/BalanceState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ data class BalanceState(
val balanceInTransferToSpending: ULong = 0uL,
val hardwareWallets: List<HwWalletBalance> = emptyList(),
) {
val totalSats get() = totalOnchainSats + totalLightningSats
val totalSats
get() = totalOnchainSats
.safe()
.plus(totalLightningSats.safe())
.safe()
.plus(balanceInTransferToSavings.safe())
.safe()
.plus(balanceInTransferToSpending.safe())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the funding correction to manual opens

When users open an external/manual channel, ExternalNodeViewModel records a MANUAL_SETUP transfer with a funding tx but no lspOrderId, so the new on-chain correction in DeriveBalanceStateUseCase never applies to it. With this added balanceInTransferToSpending term, if LDK still reports the pre-open on-chain balance while the channel is pending, the home total becomes stale savings plus the pending channel amount until the wallet sync catches up.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation, intentionally out of scope: manual opens go through LDK's connectOpenChannel, which funds and broadcasts from LDK's own wallet in one step, so the stale-balance window is at most one sync cycle. iOS's equivalent correction also filters on lspOrderId and skips manual opens — this is parity. Tracked in #1059: the planned hardening (funding-txid presence check instead of the totals heuristic) applies uniformly to LSP and manual transfers and covers this case.


val totalHardwareSats get() = hardwareWallets.fold(0uL) { acc, wallet -> acc.safe() + wallet.sats.safe() }

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/repositories/TransferRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class TransferRepo @Inject constructor(
fundingTxId: String? = null,
lspOrderId: String? = null,
claimableAtHeight: UInt? = null,
txTotalSats: Long? = null,
preTransferOnchainSats: Long? = null,
): Result<String> = withContext(bgDispatcher) {
runCatching {
val id = UUID.randomUUID().toString()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment thread
piotr-iohk marked this conversation as resolved.
val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe()
val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe()
Expand Down Expand Up @@ -95,6 +99,27 @@ class DeriveBalanceStateUseCase @Inject constructor(
return amount
}

private fun getOrderPaymentOnchainToSubtract(
activeTransfers: List<TransferEntity>,
currentOnchainSats: ULong,
): ULong {
var amount = 0uL
val orderPayments = activeTransfers.filter {
it.type.isToSpending() && it.fundingTxId != null && it.lspOrderId != null
}
Comment thread
piotr-iohk marked this conversation as resolved.

for (transfer in orderPayments) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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<TransferEntity>,
channels: List<ChannelDetails>,
Expand Down
35 changes: 32 additions & 3 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ class TransferViewModel @Inject constructor(
val address = order.payment?.onchain?.address.orEmpty()

// Use live spendableOnchainBalanceSats (not cached) to respect anchor reserves
Comment thread
piotr-iohk marked this conversation as resolved.
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,
Expand All @@ -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 {
Comment thread
piotr-iohk marked this conversation as resolved.
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",
Expand All @@ -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)
}
Expand All @@ -250,13 +275,17 @@ 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(
type = TransferType.TO_SPENDING,
amountSats = order.clientBalanceSat.toLong(),
fundingTxId = txId,
lspOrderId = order.id,
txTotalSats = txTotalSats?.toLong(),
preTransferOnchainSats = preTransferOnchainSats?.toLong(),
Comment thread
piotr-iohk marked this conversation as resolved.
)
if (createTransferActivity) {
transferRepo.createPendingToSpendingActivity(
Expand Down
23 changes: 22 additions & 1 deletion app/src/test/java/to/bitkit/models/BalanceStateTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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)
}
}
Loading
Loading