@@ -9,6 +9,7 @@ import cash.p.terminal.wallet.Wallet
99import cash.p.terminal.wallet.isLitecoinMweb
1010import cash.p.terminal.wallet.entities.BalanceData
1111import io.horizontalsystems.core.DispatcherProvider
12+ import io.horizontalsystems.core.entities.BlockchainType
1213import kotlinx.coroutines.CoroutineScope
1314import kotlinx.coroutines.Job
1415import 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}
0 commit comments