Skip to content

Commit 736e031

Browse files
committed
Add migration to track balance confirmation for pending transactions
1 parent 8553a36 commit 736e031

8 files changed

Lines changed: 173 additions & 47 deletions

File tree

app/src/main/java/cash/p/terminal/core/managers/PendingBalanceCalculator.kt

Lines changed: 89 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import cash.p.terminal.wallet.Wallet
99
import cash.p.terminal.wallet.isLitecoinMweb
1010
import cash.p.terminal.wallet.entities.BalanceData
1111
import io.horizontalsystems.core.DispatcherProvider
12+
import io.horizontalsystems.core.entities.BlockchainType
1213
import kotlinx.coroutines.CoroutineScope
1314
import kotlinx.coroutines.Job
1415
import kotlinx.coroutines.SupervisorJob
@@ -25,6 +26,10 @@ class PendingBalanceCalculator(
2526
private val pendingRepository: PendingTransactionRepository,
2627
dispatcherProvider: DispatcherProvider
2728
) : Clearable {
29+
private companion object {
30+
val CONFIRMATION_BALANCE_TOLERANCE_RATE = BigDecimal("0.05")
31+
}
32+
2833
private val pendingCache = ConcurrentHashMap<String, List<PendingTransactionEntity>>()
2934
private val observingJobs = ConcurrentHashMap<String, Job>()
3035
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io)
@@ -81,29 +86,20 @@ class PendingBalanceCalculator(
8186
token: Token,
8287
currentSdkBalance: BigDecimal
8388
): BigDecimal {
84-
val relevantPending = pendingList.filter {
85-
it.coinUid == token.coin.uid &&
86-
it.tokenTypeId == token.type.id &&
87-
it.blockchainTypeUid == token.blockchainType.uid
88-
}
89+
val relevantPending = pendingList.filter { it.matches(token) }
8990
if (relevantPending.isEmpty()) return currentSdkBalance
9091

9192
// Fee is only deducted from native token balance (not ERC-20/TRC-20/Jetton)
9293
val isNativeToken = token.type.isNative
9394

9495
// 1. Calculate total pending amount (amount + fee for native tokens only)
95-
val totalPendingAmount = relevantPending.sumOf { entity ->
96-
val amount = entity.amountAtomic.toBigDecimal().movePointLeft(token.decimals)
97-
val fee = if (isNativeToken) {
98-
entity.feeAtomic?.toBigDecimal()?.movePointLeft(token.decimals)
99-
?: BigDecimal.ZERO
100-
} else BigDecimal.ZERO
101-
amount + fee
96+
val totalPendingAmount = relevantPending.sumOf {
97+
it.amountWithFee(token.decimals, isNativeToken)
10298
}
10399

104100
// 2. Get baseline SDK balance (max from all pending - earliest "clean" balance)
105101
val baselineSdkBalance = relevantPending.maxOf { entity ->
106-
entity.sdkBalanceAtCreationAtomic.toBigDecimal().movePointLeft(token.decimals)
102+
entity.sdkBalanceAtCreation(token.decimals)
107103
}
108104

109105
// 3. How much has SDK already deducted from baseline?
@@ -116,7 +112,8 @@ class PendingBalanceCalculator(
116112
val ourDeduction = (totalPendingAmount - sdkAlreadyDeducted)
117113
.coerceAtLeast(BigDecimal.ZERO)
118114
val adjustedAfterDeduction = currentSdkBalance - ourDeduction.coerceAtMost(currentSdkBalance)
119-
val expectedMwebAvailable = if (token.isLitecoinMweb) {
115+
val isLitecoinMweb = token.isLitecoinMweb
116+
val expectedMwebAvailable = if (isLitecoinMweb) {
120117
(baselineSdkBalance - totalPendingAmount).coerceAtLeast(BigDecimal.ZERO)
121118
} else {
122119
null
@@ -125,37 +122,90 @@ class PendingBalanceCalculator(
125122
currentSdkBalance.signum() == 0 && it.signum() > 0
126123
}
127124

128-
// 5. Check for confirmed TXs to cleanup (per-TX confirmation detection)
129-
// Collect IDs first to avoid race condition during async deletion
130-
val idsToDelete = if (expectedMwebAvailable != null) {
125+
cleanupConfirmedPending(
126+
relevantPending = relevantPending,
127+
decimals = token.decimals,
128+
isNativeToken = isNativeToken,
129+
currentSdkBalance = currentSdkBalance,
130+
skipCleanupForMweb = isLitecoinMweb,
131+
)
132+
133+
return mwebZeroSnapshotFallbackAvailable ?: adjustedAfterDeduction
134+
}
135+
136+
private fun cleanupConfirmedPending(
137+
relevantPending: List<PendingTransactionEntity>,
138+
decimals: Int,
139+
isNativeToken: Boolean,
140+
currentSdkBalance: BigDecimal,
141+
skipCleanupForMweb: Boolean,
142+
) {
143+
if (skipCleanupForMweb) {
131144
// MWEB replaces spent confirmed inputs with unconfirmed change right after broadcast.
132-
emptyList()
133-
} else {
134-
relevantPending.filter { entity ->
135-
// Without a tx hash, this pending entry is the only link used to mark
136-
// the later real transaction as locally created.
137-
if (entity.txHash.isNullOrBlank()) {
138-
return@filter false
139-
}
145+
return
146+
}
140147

141-
val amount = entity.amountAtomic.toBigDecimal().movePointLeft(token.decimals)
142-
val fee = if (isNativeToken) {
143-
entity.feeAtomic?.toBigDecimal()?.movePointLeft(token.decimals)
144-
?: BigDecimal.ZERO
145-
} else BigDecimal.ZERO
146-
val sdkAtCreation = entity.sdkBalanceAtCreationAtomic.toBigDecimal()
147-
.movePointLeft(token.decimals)
148-
val expectedAfterConfirm = sdkAtCreation - amount - fee
149-
val tolerance = (amount + fee) * BigDecimal("0.05") // 5% tolerance
150-
(currentSdkBalance - expectedAfterConfirm).abs() <= tolerance
151-
}.map { it.id }
148+
val idsToDelete = mutableListOf<String>()
149+
val idsToMarkBalanceConfirmed = mutableListOf<String>()
150+
151+
relevantPending.forEach { entity ->
152+
if (!entity.isConfirmedByBalance(decimals, isNativeToken, currentSdkBalance)) {
153+
return@forEach
154+
}
155+
156+
if (entity.txHash.isNullOrBlank()) {
157+
if (entity.blockchainTypeUid == BlockchainType.Ton.uid) {
158+
// TON does not return a hash at broadcast, so the row must remain matchable.
159+
idsToMarkBalanceConfirmed.add(entity.id)
160+
}
161+
} else {
162+
idsToDelete.add(entity.id)
163+
}
152164
}
153-
if (idsToDelete.isNotEmpty()) {
154-
scope.launch {
165+
166+
if (idsToDelete.isEmpty() && idsToMarkBalanceConfirmed.isEmpty()) return
167+
168+
scope.launch {
169+
if (idsToDelete.isNotEmpty()) {
155170
tryOrNull { pendingRepository.deleteByIds(idsToDelete) }
156171
}
172+
if (idsToMarkBalanceConfirmed.isNotEmpty()) {
173+
tryOrNull { pendingRepository.markBalanceConfirmed(idsToMarkBalanceConfirmed) }
174+
}
157175
}
176+
}
158177

159-
return mwebZeroSnapshotFallbackAvailable ?: adjustedAfterDeduction
178+
private fun PendingTransactionEntity.matches(token: Token): Boolean {
179+
return coinUid == token.coin.uid &&
180+
tokenTypeId == token.type.id &&
181+
blockchainTypeUid == token.blockchainType.uid
182+
}
183+
184+
private fun PendingTransactionEntity.isConfirmedByBalance(
185+
decimals: Int,
186+
isNativeToken: Boolean,
187+
currentSdkBalance: BigDecimal,
188+
): Boolean {
189+
val amountWithFee = amountWithFee(decimals, isNativeToken)
190+
val expectedAfterConfirm = sdkBalanceAtCreation(decimals) - amountWithFee
191+
val tolerance = amountWithFee * CONFIRMATION_BALANCE_TOLERANCE_RATE
192+
return (currentSdkBalance - expectedAfterConfirm).abs() <= tolerance
193+
}
194+
195+
private fun PendingTransactionEntity.amountWithFee(
196+
decimals: Int,
197+
isNativeToken: Boolean,
198+
): BigDecimal {
199+
val amount = amountAtomic.toBigDecimal().movePointLeft(decimals)
200+
val fee = if (isNativeToken) {
201+
feeAtomic?.toBigDecimal()?.movePointLeft(decimals) ?: BigDecimal.ZERO
202+
} else {
203+
BigDecimal.ZERO
204+
}
205+
return amount + fee
206+
}
207+
208+
private fun PendingTransactionEntity.sdkBalanceAtCreation(decimals: Int): BigDecimal {
209+
return sdkBalanceAtCreationAtomic.toBigDecimal().movePointLeft(decimals)
160210
}
161211
}

app/src/main/java/cash/p/terminal/core/managers/PendingTransactionRepository.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ class PendingTransactionRepository(
7373
storage.getPendingForWallet(walletId)
7474
}
7575

76+
suspend fun markBalanceConfirmed(ids: List<String>) = mutex.withLock {
77+
withContext(dispatcherProvider.io) {
78+
storage.markBalanceConfirmed(ids, System.currentTimeMillis())
79+
startCleanupJob()
80+
}
81+
}
82+
7683
suspend fun deleteById(id: String) = mutex.withLock {
7784
withContext(dispatcherProvider.io) {
7885
storage.deleteById(id)
@@ -106,7 +113,7 @@ class PendingTransactionRepository(
106113
nonce = draft.nonce,
107114
memo = draft.memo,
108115
createdAt = draft.timestamp,
109-
expiresAt = draft.timestamp + TimeUnit.HOURS.toMillis(1)
116+
expiresAt = draft.timestamp + TimeUnit.HOURS.toMillis(1),
110117
)
111118
}
112119

app/src/main/java/cash/p/terminal/core/storage/AppDatabase.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import cash.p.terminal.core.storage.migrations.Migration_98_99
7676
import cash.p.terminal.core.storage.migrations.Migration_99_100
7777
import cash.p.terminal.core.storage.migrations.Migration_100_101
7878
import cash.p.terminal.core.storage.migrations.Migration_101_102
79+
import cash.p.terminal.core.storage.migrations.Migration_102_103
7980
import cash.p.terminal.core.storage.typeconverter.DatabaseConverters
8081
import cash.p.terminal.entities.ActiveAccount
8182
import cash.p.terminal.entities.BlockchainSettingRecord
@@ -114,7 +115,7 @@ import io.horizontalsystems.core.storage.LogEntry
114115
import io.horizontalsystems.core.storage.LogsDao
115116

116117
@Database(
117-
version = 102,
118+
version = 103,
118119
exportSchema = false,
119120
entities = [
120121
EnabledWallet::class,
@@ -267,7 +268,8 @@ abstract class AppDatabase : RoomDatabase() {
267268
Migration_98_99,
268269
Migration_99_100,
269270
Migration_100_101,
270-
Migration_101_102
271+
Migration_101_102,
272+
Migration_102_103
271273
)
272274
.build()
273275
}

app/src/main/java/cash/p/terminal/core/storage/PendingTransactionDao.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,22 @@ interface PendingTransactionDao {
1818
@Query("SELECT * FROM PendingTransaction WHERE id = :id")
1919
suspend fun getById(id: String): PendingTransactionEntity?
2020

21-
@Query("SELECT * FROM PendingTransaction WHERE expiresAt > :now AND walletId = :walletId")
21+
@Query(
22+
"""
23+
SELECT * FROM PendingTransaction
24+
WHERE expiresAt > :now
25+
AND walletId = :walletId
26+
AND balanceConfirmedAt IS NULL
27+
"""
28+
)
2229
fun getActivePendingFlow(now: Long, walletId: String): Flow<List<PendingTransactionEntity>>
2330

2431
@Query("SELECT * FROM PendingTransaction WHERE walletId = :walletId AND expiresAt > :now")
2532
suspend fun getPendingForWallet(walletId: String, now: Long): List<PendingTransactionEntity>
2633

34+
@Query("UPDATE PendingTransaction SET balanceConfirmedAt = :confirmedAt WHERE id IN (:ids)")
35+
suspend fun markBalanceConfirmed(ids: List<String>, confirmedAt: Long)
36+
2737
@Query("SELECT * FROM PendingTransaction")
2838
suspend fun getAllPending(): List<PendingTransactionEntity>
2939

app/src/main/java/cash/p/terminal/core/storage/PendingTransactionStorage.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cash.p.terminal.core.storage
22

33
import cash.p.terminal.entities.PendingTransactionEntity
44
import kotlinx.coroutines.flow.Flow
5-
import kotlinx.coroutines.flow.map
65

76
class PendingTransactionStorage(appDatabase: AppDatabase) {
87
private val dao = appDatabase.pendingTransactionDao()
@@ -19,6 +18,11 @@ class PendingTransactionStorage(appDatabase: AppDatabase) {
1918
suspend fun getPendingForWallet(walletId: String): List<PendingTransactionEntity> =
2019
dao.getPendingForWallet(walletId, System.currentTimeMillis())
2120

21+
suspend fun markBalanceConfirmed(ids: List<String>, confirmedAt: Long) {
22+
if (ids.isEmpty()) return
23+
dao.markBalanceConfirmed(ids, confirmedAt)
24+
}
25+
2226
suspend fun deleteById(id: String) = dao.deleteById(id)
2327

2428
suspend fun getExpired(): List<PendingTransactionEntity> =
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package cash.p.terminal.core.storage.migrations
2+
3+
import androidx.room.migration.Migration
4+
import androidx.sqlite.db.SupportSQLiteDatabase
5+
6+
/**
7+
* Keeps hashless pending rows available for transaction matching after balance-side confirmation.
8+
*/
9+
object Migration_102_103 : Migration(102, 103) {
10+
override fun migrate(db: SupportSQLiteDatabase) {
11+
db.execSQL("ALTER TABLE PendingTransaction ADD COLUMN balanceConfirmedAt INTEGER")
12+
}
13+
}

app/src/main/java/cash/p/terminal/entities/PendingTransactionEntity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ data class PendingTransactionEntity(
3131

3232
// Lifecycle
3333
val createdAt: Long,
34-
val expiresAt: Long
34+
val expiresAt: Long,
35+
val balanceConfirmedAt: Long? = null
3536
)

app/src/test/java/cash/p/terminal/core/managers/PendingBalanceCalculatorTest.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ class PendingBalanceCalculatorTest {
163163
advanceUntilIdle()
164164

165165
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
166+
coVerify(exactly = 0) { pendingRepository.markBalanceConfirmed(any()) }
166167
}
167168

168169
@Test
@@ -188,6 +189,7 @@ class PendingBalanceCalculatorTest {
188189
adjusted.available.stripTrailingZeros()
189190
)
190191
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
192+
coVerify(exactly = 0) { pendingRepository.markBalanceConfirmed(any()) }
191193
}
192194

193195
@Test
@@ -206,11 +208,14 @@ class PendingBalanceCalculatorTest {
206208
val rawBalance = BalanceData(available = BigDecimal("0.014"))
207209

208210
val adjusted = calculator.adjustBalance(wallet, rawBalance)
211+
advanceUntilIdle()
209212

210213
assertEquals(
211214
BigDecimal("0.014").stripTrailingZeros(),
212215
adjusted.available.stripTrailingZeros()
213216
)
217+
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
218+
coVerify(exactly = 0) { pendingRepository.markBalanceConfirmed(any()) }
214219
}
215220

216221
@Test
@@ -231,25 +236,58 @@ class PendingBalanceCalculatorTest {
231236
val rawBalance = BalanceData(available = BigDecimal("0.015"))
232237

233238
val adjusted = calculator.adjustBalance(wallet, rawBalance)
239+
advanceUntilIdle()
234240

235241
assertEquals(
236242
BigDecimal("0.015").stripTrailingZeros(),
237243
adjusted.available.stripTrailingZeros()
238244
)
245+
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
246+
coVerify(exactly = 0) { pendingRepository.markBalanceConfirmed(any()) }
239247
}
240248

241249
@Test
242-
fun adjustBalance_pendingWithoutTxHashSdkDeductedImmediately_doesNotDeletePending() = runTest(dispatcher) {
250+
fun adjustBalance_hashlessTonSdkDeductedImmediately_marksBalanceConfirmed() = runTest(dispatcher) {
243251
val calculator = PendingBalanceCalculator(pendingRepository, dispatcherProvider)
244252
calculator.onPendingInserted(createPendingEntity("ton-pending-without-hash"))
245253

246254
val wallet = createMockWallet()
247255
val rawBalance = BalanceData(available = BigDecimal("7.2657"))
248256

249-
calculator.adjustBalance(wallet, rawBalance)
257+
val adjusted = calculator.adjustBalance(wallet, rawBalance)
250258
advanceUntilIdle()
251259

260+
assertEquals(
261+
rawBalance.available.stripTrailingZeros(),
262+
adjusted.available.stripTrailingZeros()
263+
)
264+
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
265+
coVerify(exactly = 1) { pendingRepository.markBalanceConfirmed(listOf("ton-pending-without-hash")) }
266+
}
267+
268+
@Test
269+
fun adjustBalance_afterBalanceConfirmed_subsequentIncomingNotAbsorbed() = runTest(dispatcher) {
270+
val calculator = PendingBalanceCalculator(pendingRepository, dispatcherProvider)
271+
val wallet = createMockWallet()
272+
273+
calculator.startObserving("account-1")
274+
pendingFlow.value = listOf(createPendingEntity("ton-pending-without-hash"))
275+
advanceUntilIdle()
276+
277+
calculator.adjustBalance(wallet, BalanceData(available = BigDecimal("7.2657")))
278+
advanceUntilIdle()
279+
280+
pendingFlow.value = emptyList()
281+
advanceUntilIdle()
282+
283+
val afterIncoming = calculator.adjustBalance(wallet, BalanceData(available = BigDecimal("12.2657")))
284+
285+
assertEquals(
286+
BigDecimal("12.2657").stripTrailingZeros(),
287+
afterIncoming.available.stripTrailingZeros()
288+
)
252289
coVerify(exactly = 0) { pendingRepository.deleteByIds(any()) }
290+
coVerify(exactly = 1) { pendingRepository.markBalanceConfirmed(listOf("ton-pending-without-hash")) }
253291
}
254292

255293
@Test
@@ -269,6 +307,7 @@ class PendingBalanceCalculatorTest {
269307
advanceUntilIdle()
270308

271309
coVerify(exactly = 1) { pendingRepository.deleteByIds(listOf("ton-pending-with-hash")) }
310+
coVerify(exactly = 0) { pendingRepository.markBalanceConfirmed(any()) }
272311
}
273312

274313
private fun createMockWallet(): Wallet {

0 commit comments

Comments
 (0)