diff --git a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt index 516d28850c..c71e5f343f 100644 --- a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt +++ b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt @@ -20,7 +20,9 @@ data class OpenOrderRequest( @SerializedName("stop_loss_price") val stopLossPrice: String? = null, @SerializedName("destination") - val destination: String? = null + val destination: String? = null, + @SerializedName("leader_position_id") + val leaderPositionId: String? = null, ) data class OpenOrderResponse( diff --git a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt index bcc75ae4f7..485a60573d 100644 --- a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt @@ -35,6 +35,7 @@ import one.mixin.android.ui.conversation.link.LinkBottomSheetDialogFragment import one.mixin.android.ui.device.ConfirmBottomFragment import one.mixin.android.ui.forward.ForwardActivity import one.mixin.android.ui.home.web3.trade.SwapActivity +import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES import one.mixin.android.ui.home.web3.trade.TradeFragment.Companion.PREF_TRADE_SELECTED_TAB_PREFIX import one.mixin.android.ui.home.web3.trade.TradeFragment.Companion.TAB_PERPETUAL import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity @@ -56,6 +57,7 @@ import one.mixin.android.vo.getShareCategory import one.mixin.android.web3.js.Web3Signer import one.mixin.android.widget.gallery.MimeType import timber.log.Timber +import java.math.BigDecimal import java.net.URI import java.net.URLDecoder import java.nio.charset.StandardCharsets @@ -378,6 +380,14 @@ fun String.checkTradeUrl( internal data class PerpsTradeAction( val marketId: String?, + val leaderPositionId: String?, + val openPosition: PerpsOpenPositionAction? = null, +) + +internal data class PerpsOpenPositionAction( + val isLong: Boolean, + val leverage: Int?, + val margin: String?, ) internal data class SpotTradeAction( @@ -399,9 +409,38 @@ internal fun String.toPerpsTradeAction(): PerpsTradeAction? { return null } - val marketId = query.queryParameter("market")?.takeIf(String::isNotBlank) ?: return PerpsTradeAction(null) + val hasLeaderPositionId = query.hasQueryParameter("leader_position") + val leaderPositionId = query.queryParameter("leader_position") + if (hasLeaderPositionId && (leaderPositionId == null || !leaderPositionId.isUUID())) return null + val marketId = query.queryParameter("market")?.takeIf(String::isNotBlank) + ?: return if (!hasLeaderPositionId) PerpsTradeAction(null, null) else null if (!marketId.isUUID()) return null - return PerpsTradeAction(marketId) + + val isLong = if (query.queryParameter("action").equals("open", true)) { + when { + query.queryParameter("side").equals("long", true) -> true + query.queryParameter("side").equals("short", true) -> false + else -> null + } + } else { + null + } + val openPosition = isLong?.let { + val hasLeverage = query.hasQueryParameter("leverage") + val leverage = query.queryParameter("leverage")?.toIntOrNull()?.takeIf { value -> value > 0 } + if (hasLeverage && leverage == null) return null + + val hasMargin = query.hasQueryParameter("margin") + val margin = query.queryParameter("margin")?.toPerpsMarginOrNull() + if (hasMargin && margin == null) return null + + PerpsOpenPositionAction( + isLong = it, + leverage = leverage, + margin = margin, + ) + } + return PerpsTradeAction(marketId, leaderPositionId, openPosition) } internal fun String.toSpotTradeAction(): SpotTradeAction? { @@ -487,14 +526,31 @@ private suspend fun openLocalPerpsTradeAction( PerpsDatabase.getDatabase(context, identityNumber).perpsMarketDao().getMarket(marketId) } ?: return false - PerpsActivity.showDetail( - context, - market.marketId, - market.displaySymbol, - market.displaySymbol, - market.tokenSymbol, - tradeSource(context), - ) + val openPosition = action.openPosition + if (openPosition == null) { + PerpsActivity.showDetail( + context, + market.marketId, + market.displaySymbol, + market.displaySymbol, + market.tokenSymbol, + tradeSource(context), + leaderPositionId = action.leaderPositionId, + ) + } else { + PerpsActivity.showOpenPosition( + context = context, + marketId = market.marketId, + marketSymbol = market.displaySymbol, + marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol, + isLong = openPosition.isLong, + source = tradeSource(context), + leaderPositionId = action.leaderPositionId, + initialLeverage = openPosition.leverage, + initialMargin = openPosition.margin, + ) + } closeSourceWebActivityIfNeeded(context) return true } @@ -539,9 +595,31 @@ private fun String.queryParameter(name: String): String? = } .firstOrNull() +private fun String.hasQueryParameter(name: String): Boolean = + split("&").any { parameter -> + parameter.substringBefore("=").urlDecode() == name + } + private fun String.urlDecode(): String? = runCatching { URLDecoder.decode(this, StandardCharsets.UTF_8.name()) }.getOrNull() +private fun String.toPerpsMarginOrNull(): String? { + if (isEmpty() || length > 64) return null + var hasDigit = false + var hasDecimalPoint = false + for (character in this) { + when { + character.isDigit() -> hasDigit = true + character == '.' && !hasDecimalPoint -> hasDecimalPoint = true + else -> return null + } + } + if (!hasDigit) return null + val amount = toBigDecimalOrNull()?.stripTrailingZeros() ?: return null + if (amount <= BigDecimal.ZERO || amount.scale() > TRADE_INPUT_MAX_DECIMAL_PLACES) return null + return amount.toPlainString() +} + private fun getUserOrAppNotFoundTip(isApp: Boolean) = if (isApp) R.string.Bot_not_found else R.string.User_not_found diff --git a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index 34a7f050f6..c72940cb80 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt @@ -55,6 +55,7 @@ import one.mixin.android.extension.isValidStartParam import one.mixin.android.extension.putInt import one.mixin.android.extension.stripAmountZero import one.mixin.android.extension.toast +import one.mixin.android.extension.toPerpsTradeAction import one.mixin.android.extension.withArgs import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshAssetsJob @@ -1105,8 +1106,13 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { val type = uri.getQueryParameter("type") if (type.equals("perps", true) || type.equals("perpetual", true)) { - val marketId = uri.getQueryParameter("market") - if (marketId.isNullOrBlank() || !marketId.isUUID()) { + val action = uri.toString().toPerpsTradeAction() + if (action == null) { + showError(R.string.Data_error) + return + } + val marketId = action.marketId + if (marketId == null) { defaultSharedPreferences.putInt( "$PREF_TRADE_SELECTED_TAB_PREFIX${Session.getAccountId() ?: ""}", TAB_PERPETUAL, @@ -1127,18 +1133,36 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { return } - PerpsActivity.showDetail( - requireContext(), - market.marketId, - market.displaySymbol, - market.displaySymbol, - market.tokenSymbol, - if (activity is ConversationActivity) { - AnalyticsTracker.MarketDetailSource.APP_CARD - } else { - AnalyticsTracker.MarketDetailSource.SCHEMA - }, - ) + val source = if (activity is ConversationActivity) { + AnalyticsTracker.MarketDetailSource.APP_CARD + } else { + AnalyticsTracker.MarketDetailSource.SCHEMA + } + val openPosition = action.openPosition + if (openPosition == null) { + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.displaySymbol, + market.displaySymbol, + market.tokenSymbol, + source, + leaderPositionId = action.leaderPositionId, + ) + } else { + PerpsActivity.showOpenPosition( + context = requireContext(), + marketId = market.marketId, + marketSymbol = market.displaySymbol, + marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol, + isLong = openPosition.isLong, + source = source, + leaderPositionId = action.leaderPositionId, + initialLeverage = openPosition.leverage, + initialMargin = openPosition.margin, + ) + } closeSourceWebActivityIfNeeded() dismiss() return diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index 2fcd9a9475..562069c28b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -127,10 +128,14 @@ fun OpenPositionPage( isLong: Boolean, source: String, onBack: () -> Unit, + onOrderCreated: () -> Unit = {}, onOpenSuccess: (String) -> Unit = { onBack() }, selectedToken: TokenItem?, onTokenSelect: () -> Unit = {}, onCurrentTokenChange: (TokenItem?) -> Unit = {}, + leaderPositionId: String? = null, + initialLeverage: Int? = null, + initialMargin: String? = null, ) { val context = LocalContext.current val waitingOtherOrdersError = stringResource(R.string.error_waiting_other_orders) @@ -145,7 +150,14 @@ fun OpenPositionPage( var currentMarket by remember(marketId) { mutableStateOf(market) } var currentToken by remember { mutableStateOf(selectedToken) } var availableTokens by remember { mutableStateOf>(emptyList()) } - var usdtAmount by remember { mutableStateOf("") } + var usdtAmount by rememberSaveable(marketId, initialMargin) { + mutableStateOf( + limitTradeInputDecimalPlaces( + initialMargin.orEmpty(), + TRADE_INPUT_MAX_DECIMAL_PLACES, + ), + ) + } var takeProfitPrice by remember { mutableStateOf("") } var stopLossPrice by remember { mutableStateOf("") } var remoteLiquidationPrice by remember { mutableStateOf(null) } @@ -160,7 +172,15 @@ fun OpenPositionPage( .getInt(getLeveragePrefKey(marketId), DEFAULT_LEVERAGE) .coerceAtLeast(1) } - var leverage by remember(marketId) { mutableFloatStateOf(savedLeverage.toFloat()) } + var leverage by rememberSaveable(marketId, initialLeverage) { + mutableFloatStateOf( + resolveInitialPerpsLeverage( + requestedLeverage = initialLeverage, + savedLeverage = savedLeverage, + maxLeverage = market.leverage, + ).toFloat(), + ) + } LaunchedEffect(marketId) { while (true) { @@ -216,7 +236,9 @@ fun OpenPositionPage( val boundedLeverage = leverage.coerceIn(1f, maxLeverage.toFloat()) if (boundedLeverage != leverage) { leverage = boundedLeverage - context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), boundedLeverage.toInt()) + if (initialLeverage == null) { + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), boundedLeverage.toInt()) + } } } @@ -783,8 +805,10 @@ fun OpenPositionPage( // Null means "leave TP/SL unset" when creating a new position. takeProfitPrice = takeProfitPrice.takeIf { it.isNotBlank() }, stopLossPrice = stopLossPrice.takeIf { it.isNotBlank() }, + leaderPositionId = leaderPositionId, entryPrice = m.last, onSuccess = { response -> + onOrderCreated() PerpsConfirmBottomSheetDialogFragment.newInstance( marketSymbol = m.displaySymbol, marketIcon = m.iconUrl, @@ -886,6 +910,12 @@ fun OpenPositionPage( } +internal fun resolveInitialPerpsLeverage( + requestedLeverage: Int?, + savedLeverage: Int, + maxLeverage: Int, +): Int = (requestedLeverage ?: savedLeverage).coerceIn(1, maxLeverage.coerceAtLeast(1)) + private fun generateLeverageOptions(maxLeverage: Int): List { val safeMaxLeverage = maxLeverage.coerceAtLeast(1) val baseOptions = listOf(5, 10, 20) 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 cece93af00..83e6ee534b 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 @@ -444,6 +444,7 @@ class PerpetualViewModel @Inject constructor( takeProfitPrice: String? = null, stopLossPrice: String? = null, destination: String? = null, + leaderPositionId: String? = null, entryPrice: String, onSuccess: (OpenOrderResponse) -> Unit, onError: (Int, String) -> Unit @@ -459,7 +460,8 @@ class PerpetualViewModel @Inject constructor( walletId = walletId, takeProfitPrice = takeProfitPrice, stopLossPrice = stopLossPrice, - destination = destination + destination = destination, + leaderPositionId = leaderPositionId, ) val response = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt index 5d6c8c82df..5bb1b03078 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,6 +26,7 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.findFragmentActivityOrNull import one.mixin.android.extension.putString import one.mixin.android.extension.toast import one.mixin.android.job.MixinJobManager @@ -48,7 +50,14 @@ class PerpsActivity : BaseActivity() { private val viewModel by viewModels() private var selectedToken by mutableStateOf(null) + private var leaderPositionId by mutableStateOf(null) private var renderJob: Job? = null + private val openPositionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + intent.removeExtra(EXTRA_LEADER_POSITION_ID) + leaderPositionId = null + } + } companion object { private const val EXTRA_MARKET_ID = "extra_market_id" @@ -59,6 +68,9 @@ class PerpsActivity : BaseActivity() { private const val EXTRA_IS_LONG = "extra_is_long" private const val EXTRA_SOURCE = "extra_source" private const val EXTRA_RETURN_TO_DETAIL = "extra_return_to_detail" + private const val EXTRA_LEADER_POSITION_ID = "extra_leader_position_id" + private const val EXTRA_INITIAL_LEVERAGE = "extra_initial_leverage" + private const val EXTRA_INITIAL_MARGIN = "extra_initial_margin" private const val POSITION_REFRESH_INTERVAL_MS = 3_000L const val MODE_DETAIL = "detail" @@ -72,6 +84,7 @@ class PerpsActivity : BaseActivity() { marketTokenSymbol: String = "", source: String? = null, reuseCurrentActivity: Boolean = true, + leaderPositionId: String? = null, ) { val intent = Intent(context, PerpsActivity::class.java).apply { if (context !is Activity) { @@ -86,6 +99,7 @@ class PerpsActivity : BaseActivity() { putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) putExtra(EXTRA_MODE, MODE_DETAIL) source?.let { putExtra(EXTRA_SOURCE, it) } + leaderPositionId?.let { putExtra(EXTRA_LEADER_POSITION_ID, it) } } context.startActivity(intent) } @@ -99,6 +113,9 @@ class PerpsActivity : BaseActivity() { isLong: Boolean, source: String, returnToDetail: Boolean = false, + leaderPositionId: String? = null, + initialLeverage: Int? = null, + initialMargin: String? = null, ) { val intent = Intent(context, PerpsActivity::class.java).apply { if (context !is Activity) { @@ -112,8 +129,16 @@ class PerpsActivity : BaseActivity() { putExtra(EXTRA_IS_LONG, isLong) putExtra(EXTRA_SOURCE, source) putExtra(EXTRA_RETURN_TO_DETAIL, returnToDetail) + leaderPositionId?.let { putExtra(EXTRA_LEADER_POSITION_ID, it) } + initialLeverage?.let { putExtra(EXTRA_INITIAL_LEVERAGE, it) } + initialMargin?.let { putExtra(EXTRA_INITIAL_MARGIN, it) } + } + val hostActivity = context.findFragmentActivityOrNull() as? PerpsActivity + if (hostActivity != null && returnToDetail && leaderPositionId != null) { + hostActivity.openPositionLauncher.launch(intent) + } else { + context.startActivity(intent) } - context.startActivity(intent) } } @@ -140,17 +165,42 @@ class PerpsActivity : BaseActivity() { val isLong = currentIntent.getBooleanExtra(EXTRA_IS_LONG, true) val source = currentIntent.getStringExtra(EXTRA_SOURCE) ?: AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL val returnToDetail = currentIntent.getBooleanExtra(EXTRA_RETURN_TO_DETAIL, false) + leaderPositionId = currentIntent.getStringExtra(EXTRA_LEADER_POSITION_ID) + val initialLeverage = currentIntent + .takeIf { it.hasExtra(EXTRA_INITIAL_LEVERAGE) } + ?.getIntExtra(EXTRA_INITIAL_LEVERAGE, 0) + val initialMargin = currentIntent.getStringExtra(EXTRA_INITIAL_MARGIN) if (mode == MODE_OPEN_POSITION) { renderJob = lifecycleScope.launch { - val market = withContext(Dispatchers.IO) { - perpsMarketDao.getMarket(marketId) - } + val market = viewModel.getMarketById(marketId) if (market == null) { toast(R.string.Alert_Not_Support) finish() return@launch } + val walletId = Session.getAccountId() + val hasOpenPosition = if (walletId.isNullOrEmpty()) { + false + } else { + viewModel.getOpenPositionsFromDb(walletId).any { it.marketId == marketId } + } + if (!canOpenNewPerpsPosition(hasOpenPosition)) { + if (returnToDetail) { + finish() + } else { + showDetail( + context = this@PerpsActivity, + marketId = market.marketId, + marketSymbol = market.displaySymbol, + marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol, + source = source, + leaderPositionId = leaderPositionId, + ) + } + return@launch + } AnalyticsTracker.trackPerpsOpenStart( direction = if (isLong) AnalyticsTracker.PerpsDirection.LONG else AnalyticsTracker.PerpsDirection.SHORT, source = source, @@ -163,6 +213,15 @@ class PerpsActivity : BaseActivity() { isLong = isLong, source = source, onBack = { finish() }, + onOrderCreated = { + if (leaderPositionId != null) { + intent.removeExtra(EXTRA_LEADER_POSITION_ID) + leaderPositionId = null + } + if (returnToDetail) { + setResult(Activity.RESULT_OK) + } + }, onOpenSuccess = { openedMarketId -> if (returnToDetail) { finish() @@ -174,7 +233,10 @@ class PerpsActivity : BaseActivity() { onTokenSelect = { showTokenSelection() }, onCurrentTokenChange = { token -> selectedToken = token - } + }, + leaderPositionId = leaderPositionId, + initialLeverage = initialLeverage, + initialMargin = initialMargin, ) } } @@ -206,6 +268,7 @@ class PerpsActivity : BaseActivity() { onBack = { finish() }, onSharePosition = ::showSharePosition, source = source, + leaderPositionId = leaderPositionId, ) } } @@ -255,3 +318,5 @@ class PerpsActivity : BaseActivity() { .show(supportFragmentManager, PerpsPositionShareBottomFragment.TAG) } } + +internal fun canOpenNewPerpsPosition(hasOpenPosition: Boolean): Boolean = !hasOpenPosition diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index f4ddea7df9..477b89c02a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -107,6 +107,7 @@ fun PerpsMarketDetailPage( onBack: () -> Unit, onSharePosition: (PerpsPositionItem) -> Unit, source: String, + leaderPositionId: String? = null, ) { val context = LocalContext.current val viewModel = hiltViewModel() @@ -706,6 +707,7 @@ fun PerpsMarketDetailPage( isLong = true, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL, returnToDetail = true, + leaderPositionId = leaderPositionId, ) }, backgroundColor = risingColor, @@ -732,6 +734,7 @@ fun PerpsMarketDetailPage( isLong = false, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL, returnToDetail = true, + leaderPositionId = leaderPositionId, ) }, backgroundColor = fallingColor, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt index 48be72167a..c72e613084 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt @@ -7,7 +7,17 @@ import one.mixin.android.extension.toSpotTradeAction internal sealed interface WalletHomeBannerActionTarget { data class SpotTrade(val action: SpotTradeAction) : WalletHomeBannerActionTarget - data class PerpsMarket(val marketId: String) : WalletHomeBannerActionTarget + data class PerpsMarket( + val marketId: String, + val leaderPositionId: String? = null, + ) : WalletHomeBannerActionTarget + data class PerpsOpen( + val marketId: String, + val isLong: Boolean, + val leverage: Int?, + val margin: String?, + val leaderPositionId: String?, + ) : WalletHomeBannerActionTarget data object PerpsTab : WalletHomeBannerActionTarget data object Buy : WalletHomeBannerActionTarget data class Web(val url: String) : WalletHomeBannerActionTarget @@ -18,7 +28,15 @@ internal fun String.toClassicWalletHomeBannerActionTarget(): WalletHomeBannerAct toPerpsTradeAction()?.let { action -> return when (val marketId = action.marketId) { null -> WalletHomeBannerActionTarget.PerpsTab - else -> WalletHomeBannerActionTarget.PerpsMarket(marketId) + else -> action.openPosition?.let { openPosition -> + WalletHomeBannerActionTarget.PerpsOpen( + marketId = marketId, + isLong = openPosition.isLong, + leverage = openPosition.leverage, + margin = openPosition.margin, + leaderPositionId = action.leaderPositionId, + ) + } ?: WalletHomeBannerActionTarget.PerpsMarket(marketId, action.leaderPositionId) } } return if (isBuyAction()) { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index 897df27dac..90c95e5baf 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -939,6 +939,21 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) "", "", AnalyticsTracker.PerpsSource.WALLET_HOME, + leaderPositionId = target.leaderPositionId, + ) + } + is WalletHomeBannerActionTarget.PerpsOpen -> { + PerpsActivity.showOpenPosition( + context = requireActivity(), + marketId = target.marketId, + marketSymbol = "", + marketDisplaySymbol = "", + marketTokenSymbol = "", + isLong = target.isLong, + source = AnalyticsTracker.PerpsSource.WALLET_HOME, + leaderPositionId = target.leaderPositionId, + initialLeverage = target.leverage, + initialMargin = target.margin, ) } WalletHomeBannerActionTarget.PerpsTab -> { diff --git a/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt b/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt new file mode 100644 index 0000000000..755e9ec24f --- /dev/null +++ b/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt @@ -0,0 +1,37 @@ +package one.mixin.android.api.request.perps + +import com.google.gson.Gson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class OpenOrderRequestTest { + @Test + fun serializesLeaderPositionId() { + val leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + val json = Gson().toJsonTree(openOrderRequest(leaderPositionId)).asJsonObject + + assertEquals(leaderPositionId, json.get("leader_position_id").asString) + assertFalse(json.has("leader_position")) + assertFalse(json.has("position_id")) + } + + @Test + fun omitsLeaderPositionIdForRegularOrder() { + val json = Gson().toJsonTree(openOrderRequest()).asJsonObject + + assertFalse(json.has("leader_position_id")) + } + + private fun openOrderRequest(leaderPositionId: String? = null) = + OpenOrderRequest( + assetId = "c6d0c728-2624-429b-8e0d-d9d19b6592fa", + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + side = "long", + amount = "10", + leverage = 5, + walletId = "41d16c28-0c3a-493d-a2b4-b57875371abf", + leaderPositionId = leaderPositionId, + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt index 4a654ebf91..331343e621 100644 --- a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt +++ b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.conversation import one.mixin.android.extension.PerpsTradeAction +import one.mixin.android.extension.PerpsOpenPositionAction import one.mixin.android.extension.SpotTradeAction import one.mixin.android.extension.toPerpsTradeAction import one.mixin.android.extension.toSpotTradeAction @@ -51,6 +52,112 @@ class PerpsTradeActionTest { assertEquals("e015f42e-b0ff-38e7-87b1-7e8d46fea119", action.toPerpsTradeAction()?.marketId) } + @Test + fun parsesPerpsLeaderPositionId() { + val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + assertEquals( + PerpsTradeAction( + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + ), + action.toPerpsTradeAction(), + ) + } + + @Test + fun rejectsInvalidPerpsLeaderPositionId() { + val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=invalid" + + assertNull(action.toPerpsTradeAction()) + } + + @Test + fun rejectsMalformedEncodedPerpsLeaderPositionId() { + val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=%ZZ" + + assertNull(action.toPerpsTradeAction()) + } + + @Test + fun rejectsPerpsLeaderPositionIdWithoutMarket() { + val action = "https://mixin.one/trade?type=perps&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + assertNull(action.toPerpsTradeAction()) + } + + @Test + fun parsesDirectPerpsOpenAction() { + val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&action=open&side=short&leverage=10&margin=10&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + assertEquals( + PerpsTradeAction( + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + openPosition = PerpsOpenPositionAction( + isLong = false, + leverage = 10, + margin = "10", + ), + ), + action.toPerpsTradeAction(), + ) + } + + @Test + fun directPerpsOpenRequiresOpenActionAndValidSide() { + val base = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + assertNull("$base&side=long".toPerpsTradeAction()?.openPosition) + assertNull("$base&action=open".toPerpsTradeAction()?.openPosition) + assertNull("$base&action=close&side=long".toPerpsTradeAction()?.openPosition) + assertNull("$base&action=open&side=invalid".toPerpsTradeAction()?.openPosition) + assertEquals( + "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + "$base&action=open".toPerpsTradeAction()?.leaderPositionId, + ) + } + + @Test + fun directPerpsOpenAcceptsOptionalValues() { + val action = "mixin://mixin.one/trade?type=perpetual&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&action=OPEN&side=LONG" + + assertEquals( + PerpsOpenPositionAction( + isLong = true, + leverage = null, + margin = null, + ), + action.toPerpsTradeAction()?.openPosition, + ) + } + + @Test + fun rejectsInvalidDirectPerpsOpenDefaults() { + val base = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&action=open&side=long" + + assertNull("$base&leverage=0".toPerpsTradeAction()) + assertNull("$base&leverage=1.5".toPerpsTradeAction()) + assertNull("$base&margin=0".toPerpsTradeAction()) + assertNull("$base&margin=-1".toPerpsTradeAction()) + assertNull("$base&margin=0.000000001".toPerpsTradeAction()) + assertNull("$base&margin=1e2".toPerpsTradeAction()) + assertNull("$base&margin=${"1".repeat(65)}".toPerpsTradeAction()) + } + + @Test + fun detailPerpsActionKeepsLeaderAndIgnoresOpenDefaults() { + val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leverage=invalid&margin=invalid&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" + + assertEquals( + PerpsTradeAction( + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + ), + action.toPerpsTradeAction(), + ) + } + @Test fun parsesMixinPerpetualTradeAction() { val action = "mixin://mixin.one/trade?type=perpetual&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119" @@ -124,6 +231,31 @@ class PerpsTradeActionTest { ) } + @Test + fun classicBannerActionKeepsPerpsLeaderPositionId() { + assertEquals( + WalletHomeBannerActionTarget.PerpsMarket( + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + ), + "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1".toClassicWalletHomeBannerActionTarget(), + ) + } + + @Test + fun classicBannerActionParsesDirectPerpsOpenTarget() { + assertEquals( + WalletHomeBannerActionTarget.PerpsOpen( + marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119", + isLong = true, + leverage = 10, + margin = "25.5", + leaderPositionId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1", + ), + "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&action=open&side=long&leverage=10&margin=25.5&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1".toClassicWalletHomeBannerActionTarget(), + ) + } + @Test fun classicBannerActionFallsBackToWebTarget() { val url = "https://mixin.one/users/41d16c28-0c3a-493d-a2b4-b57875371abf" diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionDefaultsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionDefaultsTest.kt new file mode 100644 index 0000000000..ef1868d6fe --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionDefaultsTest.kt @@ -0,0 +1,50 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import kotlin.test.Test +import kotlin.test.assertEquals + +class OpenPositionDefaultsTest { + @Test + fun requestedLeverageOverridesSavedValueWithinMarketBounds() { + assertEquals( + 20, + resolveInitialPerpsLeverage( + requestedLeverage = 20, + savedLeverage = 5, + maxLeverage = 100, + ), + ) + } + + @Test + fun requestedLeverageIsBoundedByMarket() { + assertEquals( + 100, + resolveInitialPerpsLeverage( + requestedLeverage = 200, + savedLeverage = 5, + maxLeverage = 100, + ), + ) + assertEquals( + 1, + resolveInitialPerpsLeverage( + requestedLeverage = 0, + savedLeverage = 5, + maxLeverage = 100, + ), + ) + } + + @Test + fun savedLeverageIsUsedWhenSchemaOmitsIt() { + assertEquals( + 10, + resolveInitialPerpsLeverage( + requestedLeverage = null, + savedLeverage = 10, + maxLeverage = 100, + ), + ) + } +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivityRoutingTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivityRoutingTest.kt new file mode 100644 index 0000000000..40ee19b20f --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivityRoutingTest.kt @@ -0,0 +1,17 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PerpsActivityRoutingTest { + @Test + fun existingPositionKeepsMarketDetail() { + assertFalse(canOpenNewPerpsPosition(hasOpenPosition = true)) + } + + @Test + fun missingPositionOpensNewPositionPage() { + assertTrue(canOpenNewPerpsPosition(hasOpenPosition = false)) + } +}