From 30db70c1ba05b860147835be382f27ea2af725f7 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 7 Oct 2026 19:52:33 +0800 Subject: [PATCH 1/3] fix(perps): avoid premature and stale position updates --- .../android/db/perps/PerpsPositionDao.kt | 19 +++- .../web3/trade/perps/PerpetualViewModel.kt | 21 +---- .../PerpsAddBottomSheetDialogFragment.kt | 1 - .../android/db/perps/PerpsPositionDaoTest.kt | 93 +++++++++++++++++++ 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index f71d3febc2..330fee4e19 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -5,18 +5,33 @@ import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Transaction import kotlinx.coroutines.flow.Flow import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.BaseDao +import org.threeten.bp.OffsetDateTime @Dao interface PerpsPositionDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(position: PerpsPosition) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(positions: List) + @Transaction + suspend fun insertAll(positions: List) { + positions.forEach { upsertSuspend(it) } + } + + @Transaction + override suspend fun upsertSuspend(entity: PerpsPosition) { + val current = getPosition(entity.positionId) + // Optimistic opening/adding states can carry a client-generated timestamp. + if (current != null && current.state != PerpsPosition.STATE_OPENING && current.state != PerpsPosition.STATE_ADDING) { + val updatedAt = current.updatedAt?.let(OffsetDateTime::parse) + if (updatedAt != null && OffsetDateTime.parse(entity.updatedAt).isBefore(updatedAt)) return + } + insert(entity) + } @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index b157fa7163..641c895420 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -692,7 +692,6 @@ class PerpetualViewModel @Inject constructor( positionId: String, assetId: String, amount: String, - position: PerpsPositionItem? = null, destination: String? = null, price: String? = null, takeProfitPrice: String? = null, @@ -719,21 +718,6 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date()) - withContext(Dispatchers.IO) { - val localPosition = perpsPositionDao.getPosition(positionId)?.toPosition() - ?: position?.toPosition() - if (localPosition != null) { - perpsPositionDao.upsertSuspend( - localPosition.copy( - state = PerpsPosition.STATE_ADDING, - updatedAt = now, - ) - ) - } else { - perpsPositionDao.updateStatus(positionId, PerpsPosition.STATE_ADDING, now) - } - } onSuccess(data) } else { onError(response.errorCode, response.errorDescription) @@ -1115,11 +1099,12 @@ class PerpetualViewModel @Inject constructor( val resolvedWalletId = data.walletId.ifBlank { localBefore?.walletId ?: "" } val positionForDb = data.copy(walletId = resolvedWalletId) - withContext(Dispatchers.IO) { + val currentPosition = withContext(Dispatchers.IO) { perpsPositionDao.upsertSuspend(positionForDb) + perpsPositionDao.getPosition(positionId)?.toPosition() ?: positionForDb } - onSuccess(positionForDb) + onSuccess(currentPosition) } else { val error = "Failed to load position detail: ${response.errorDescription}" Timber.e(error) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt index 303e9670ff..98ba96c7c2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt @@ -967,7 +967,6 @@ internal fun FragmentActivity.showPerpsAddPosition( positionId = position.positionId, assetId = token.assetId, amount = amount, - position = position, price = referencePrice.takeIf { it.isNotBlank() }, leaderPositionId = leaderPositionId, onSuccess = { response -> diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt new file mode 100644 index 0000000000..1707d11564 --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt @@ -0,0 +1,93 @@ +package one.mixin.android.db.perps + +import android.content.Context +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.db.PerpsDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PerpsPositionDaoTest { + private lateinit var database: PerpsDatabase + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), PerpsDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() = database.close() + + @Test + fun olderDetailAndListSnapshotsDoNotRollBackMargin() = runBlocking { + val dao = database.perpsPositionDao() + val initial = position() + val increased = initial.copy(margin = "55", updatedAt = "2026-10-07T00:00:00.250Z") + dao.upsertSuspend(initial) + assertEquals("5", dao.getOpenPositions("wallet").single().margin) + + dao.insertAll(listOf(increased)) + dao.upsertSuspend(initial) + assertEquals("55", dao.getPosition(initial.positionId)?.margin) + dao.insertAll(listOf(initial)) + assertEquals("55", dao.getOpenPositions("wallet").single().margin) + + val reduced = increased.copy(margin = "50", updatedAt = "2026-10-07T00:00:01Z") + dao.upsertSuspend(reduced) + dao.insertAll(listOf(increased)) + assertEquals("50", dao.getPosition(initial.positionId)?.margin) + } + + @Test + fun equalVersionSnapshotsStillRefreshQuotes() = runBlocking { + val dao = database.perpsPositionDao() + val initial = position() + dao.upsertSuspend(initial) + val refreshed = initial.copy(markPrice = "101", unrealizedPnl = "1", updatedAt = "2026-10-07T08:00:00+08:00") + dao.insertAll(listOf(refreshed)) + assertEquals("101", dao.getPosition(initial.positionId)?.markPrice) + assertEquals("1", dao.getPosition(initial.positionId)?.unrealizedPnl) + } + + @Test + fun remoteSnapshotsReplaceOptimisticLocalStates() = runBlocking { + val dao = database.perpsPositionDao() + val remote = position() + for (state in listOf(PerpsPosition.STATE_OPENING, PerpsPosition.STATE_ADDING)) { + dao.insert(remote.copy(state = state, updatedAt = "2026-10-07T08:00:00Z")) + dao.upsertSuspend(remote) + assertEquals(PerpsPosition.STATE_OPEN, dao.getPosition(remote.positionId)?.state) + assertEquals(remote.updatedAt, dao.getPosition(remote.positionId)?.updatedAt) + } + } + + private fun position() = PerpsPosition( + positionId = "position", + marketId = "market", + side = "long", + quantity = "1", + entryPrice = "100", + margin = "5", + leverage = 10, + state = PerpsPosition.STATE_OPEN, + markPrice = "100", + unrealizedPnl = "0", + roe = "0", + settleAssetId = "asset", + openPayAmount = "5", + openPayAssetId = "asset", + botId = "bot", + walletId = "wallet", + createdAt = "2026-10-07T00:00:00Z", + updatedAt = "2026-10-07T00:00:00Z", + ) +} From e2deb1d04809b96892a4aa6c3b60466ffe9534b4 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 8 Oct 2026 16:01:42 +0800 Subject: [PATCH 2/3] fix(perps): hide pnl percentage in activity list --- .../ui/home/web3/trade/perps/ClosedActivityItem.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt index 8474737cd9..d0ad3bcefc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt @@ -69,7 +69,6 @@ fun ClosedActivityItem( } else { if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed } - val pnlPercent = order.roe.toBigDecimalOrNull()?.multiply(BigDecimal(100)) val titleRes = when { isFailed && isLong -> R.string.Closed_Long_Failed isFailed -> R.string.Closed_Short_Failed @@ -132,14 +131,7 @@ fun ClosedActivityItem( Spacer(modifier = Modifier.width(8.dp)) BasicText( - text = buildString { - append(formatPerpsSignedRawUsdDecimal(pnl)) - pnlPercent?.let { - append(" (") - append(formatPerpsSignedPercent(it, withSign = false)) - append(")") - } - }, + text = formatPerpsSignedRawUsdDecimal(pnl), modifier = Modifier.widthIn(max = 160.dp), style = TextStyle( fontSize = 14.sp, From 931c536b21fae541322cce1e673ee9205028df29 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 9 Oct 2026 13:19:52 +0800 Subject: [PATCH 3/3] revert(perps): restore pnl percentage in activity list --- .../ui/home/web3/trade/perps/ClosedActivityItem.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt index d16de95d51..3a6b6cea4b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/ClosedActivityItem.kt @@ -32,6 +32,7 @@ import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsOrder import one.mixin.android.api.response.perps.PerpsOrderItem import one.mixin.android.api.response.perps.realizedPnlForDisplay +import one.mixin.android.api.response.perps.roeForDisplay import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences @@ -70,6 +71,7 @@ fun ClosedActivityItem( } else { if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed } + val pnlPercent = order.roeForDisplay()?.multiply(BigDecimal(100)) val titleRes = when { isFailed && isLong -> R.string.Closed_Long_Failed isFailed -> R.string.Closed_Short_Failed @@ -132,7 +134,14 @@ fun ClosedActivityItem( Spacer(modifier = Modifier.width(8.dp)) BasicText( - text = formatPerpsSignedRawUsdDecimal(pnl), + text = buildString { + append(formatPerpsSignedRawUsdDecimal(pnl)) + pnlPercent?.let { + append(" (") + append(formatPerpsSignedPercent(it, withSign = false)) + append(")") + } + }, modifier = Modifier.widthIn(max = 160.dp), style = TextStyle( fontSize = 14.sp,