Skip to content

Commit 87cc74b

Browse files
committed
Merge remote-tracking branch 'origin/feature/MOBILE-702'
2 parents 0692114 + 736e031 commit 87cc74b

14 files changed

Lines changed: 580 additions & 56 deletions

File tree

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

Lines changed: 90 additions & 34 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,31 +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-
val amount = entity.amountAtomic.toBigDecimal().movePointLeft(token.decimals)
136-
val fee = if (isNativeToken) {
137-
entity.feeAtomic?.toBigDecimal()?.movePointLeft(token.decimals)
138-
?: BigDecimal.ZERO
139-
} else BigDecimal.ZERO
140-
val sdkAtCreation = entity.sdkBalanceAtCreationAtomic.toBigDecimal()
141-
.movePointLeft(token.decimals)
142-
val expectedAfterConfirm = sdkAtCreation - amount - fee
143-
val tolerance = (amount + fee) * BigDecimal("0.05") // 5% tolerance
144-
(currentSdkBalance - expectedAfterConfirm).abs() <= tolerance
145-
}.map { it.id }
145+
return
146146
}
147-
if (idsToDelete.isNotEmpty()) {
148-
scope.launch {
147+
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+
}
164+
}
165+
166+
if (idsToDelete.isEmpty() && idsToMarkBalanceConfirmed.isEmpty()) return
167+
168+
scope.launch {
169+
if (idsToDelete.isNotEmpty()) {
149170
tryOrNull { pendingRepository.deleteByIds(idsToDelete) }
150171
}
172+
if (idsToMarkBalanceConfirmed.isNotEmpty()) {
173+
tryOrNull { pendingRepository.markBalanceConfirmed(idsToMarkBalanceConfirmed) }
174+
}
151175
}
176+
}
152177

153-
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)
154210
}
155211
}

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package cash.p.terminal.core.managers
22

3+
import cash.p.terminal.core.tryOrNull
34
import cash.p.terminal.entities.TransactionValue
45
import cash.p.terminal.entities.transactionrecords.PendingTransactionRecord
56
import cash.p.terminal.entities.transactionrecords.TransactionRecord
@@ -12,6 +13,7 @@ import cash.p.terminal.wallet.entities.TokenType
1213
import io.horizontalsystems.core.entities.BlockchainType
1314
import io.horizontalsystems.litecoinkit.LitecoinKit
1415
import io.horizontalsystems.litecoinkit.mweb.address.MwebAddressCodec
16+
import io.horizontalsystems.tonkit.Address
1517
import java.math.BigDecimal
1618
import kotlin.math.abs
1719

@@ -90,8 +92,7 @@ class PendingTransactionMatcher {
9092
val realTo = real.to?.firstOrNull()
9193

9294
if (blockchainMatches && amountMatches && timestampMatches) {
93-
val addressMatches = realTo != null && toAddress.isNotEmpty() &&
94-
toAddress.equals(realTo, ignoreCase = true)
95+
val addressMatches = compareAddresses(blockchainTypeUid, toAddress, realTo)
9596

9697
return MatchScore(
9798
isMatch = true,
@@ -142,15 +143,36 @@ class PendingTransactionMatcher {
142143
canonicalHash.equals(real.transactionHash, ignoreCase = true) &&
143144
real.uid.matchesMwebLocalIdentifier(pendingIdentifier) &&
144145
compareAmounts(pending.amount.abs(), real) &&
145-
compareAddresses(pending.to?.firstOrNull(), real.to?.firstOrNull())
146+
compareAddresses(
147+
blockchainTypeUid = pending.blockchainType.uid,
148+
pendingTo = pending.to?.firstOrNull(),
149+
realTo = real.to?.firstOrNull()
150+
)
146151
}
147152

148-
private fun compareAddresses(pendingTo: String?, realTo: String?): Boolean {
153+
private fun compareAddresses(
154+
blockchainTypeUid: String,
155+
pendingTo: String?,
156+
realTo: String?,
157+
): Boolean {
149158
val pendingAddress = pendingTo?.takeIf { it.isNotBlank() } ?: return false
150159
val realAddress = realTo?.takeIf { it.isNotBlank() } ?: return false
160+
161+
if (blockchainTypeUid == BlockchainType.Ton.uid) {
162+
val normalizedPending = normalizeTonAddress(pendingAddress)
163+
val normalizedReal = normalizeTonAddress(realAddress)
164+
if (normalizedPending != null || normalizedReal != null) {
165+
return normalizedPending == normalizedReal
166+
}
167+
}
168+
151169
return pendingAddress.equals(realAddress, ignoreCase = true)
152170
}
153171

172+
private fun normalizeTonAddress(address: String): String? {
173+
return tryOrNull { Address.parse(address).toRaw() }
174+
}
175+
154176
private fun litecoinMwebPegInMatchScore(
155177
pending: PendingTransactionRecord,
156178
real: TransactionRecord
@@ -171,7 +193,7 @@ class PendingTransactionMatcher {
171193
val realAmount = getRealAmount(real)?.abs() ?: return null
172194
// Public peg-in spends selected public UTXOs into the extension output, including MWEB-side change.
173195
val maxPublicAmount = pendingAmount.multiply(LITECOIN_MWEB_PEG_IN_MAX_PUBLIC_AMOUNT_RATE)
174-
if (realAmount < pendingAmount || realAmount > maxPublicAmount) {
196+
if (realAmount !in pendingAmount..maxPublicAmount) {
175197
return null
176198
}
177199

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/main/java/cash/p/terminal/modules/multiswap/sendtransaction/services/SendTransactionServiceTonSwap.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ class SendTransactionServiceTonSwap(
266266
fee = null,
267267
sdkBalanceAtCreation = sdkBalance,
268268
fromAddress = "", // TON doesn't require from address
269-
toAddress = addressState.address!!.hex,
269+
toAddress = tonSwapData.routerAddress,
270270
txHash = null
271271
)
272272
pendingTxId = pendingRegistrar.register(draft)

0 commit comments

Comments
 (0)