diff --git a/app/src/main/java/one/mixin/android/api/request/web3/GaslessTxRequest.kt b/app/src/main/java/one/mixin/android/api/request/web3/GaslessTxRequest.kt index b0399abde2..55c0f6a0ca 100644 --- a/app/src/main/java/one/mixin/android/api/request/web3/GaslessTxRequest.kt +++ b/app/src/main/java/one/mixin/android/api/request/web3/GaslessTxRequest.kt @@ -11,7 +11,7 @@ data class GaslessTxRequest( @SerializedName("fee_asset_id") val feeAssetId: String, @SerializedName("fee_amount") - val feeAmount: String, + val feeAmount: String?, @SerializedName("chain_id") val chainId: String, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt index 7ec0cd08fa..959c43870a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt @@ -37,6 +37,15 @@ import one.mixin.android.ui.home.web3.trade.SwapViewModel import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal +private fun displayBalance(balance: String?, isWeb3: Boolean): String { + if (balance.isNullOrBlank()) return "0" + return if (isWeb3) { + balance.toBigDecimalOrNull()?.stripTrailingZeros()?.toPlainString() ?: balance + } else { + balance.numberFormat8() + } +} + @Composable fun InputArea( modifier: Modifier = Modifier, @@ -95,11 +104,7 @@ fun InputArea( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = if (token.isWeb3) { - balance?.numberFormat8() ?: "0" - } else { - balance?.numberFormat8() ?: "0" - }, + text = displayBalance(balance = balance, isWeb3 = token.isWeb3), style = TextStyle( fontSize = 12.sp, color = MixinAppTheme.colors.textAssist, @@ -143,4 +148,4 @@ fun InputArea( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt index 02b496f480..af2e718adc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt @@ -95,6 +95,25 @@ enum class FocusedField { NONE, IN_AMOUNT, OUT_AMOUNT, PRICE } private const val MAX_DISPLAY_ORDER_COUNT: Int = 10 +private fun formatBalanceInput(balance: String?, isWeb3: Boolean): String { + val amount = balance?.toBigDecimalOrNull() ?: return "" + if (amount <= BigDecimal.ZERO) return "" + return if (isWeb3) { + amount.stripTrailingZeros().toPlainString() + } else { + amount.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString() + } +} + +private fun formatLimitOrderAmount(value: String, isWeb3: Boolean): String { + val amount = value.toBigDecimalOrNull() ?: return value + return if (isWeb3) { + amount.stripTrailingZeros().toPlainString() + } else { + amount.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString() + } +} + enum class ExpiryOption(@get:StringRes val labelRes: Int) { NEVER(R.string.expiry_never), MIN_10(R.string.expiry_10_min), HOUR_1(R.string.expiry_1_hour), DAY_1(R.string.expiry_1_day), DAY_3(R.string.expiry_3_days), WEEK_1(R.string.expiry_1_week), MONTH_1(R.string.expiry_1_month), YEAR_1(R.string.expiry_1_year); @@ -119,6 +138,7 @@ fun LimitOrderContent( inMixin: Boolean, initialAmount: String?, lastOrderTime: Long?, + reviewing: Boolean, onSelectToken: (Boolean, SelectTokenType) -> Unit, onLimitReview: (SwapToken, SwapToken, CreateLimitOrderResponse) -> Unit, onDeposit: (SwapToken) -> Unit, @@ -297,12 +317,7 @@ fun LimitOrderContent( } } }, onDeposit = onDeposit, onMax = { - val balance = fromBalance?.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (balance > BigDecimal.ZERO) { - inputText = balance.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString() - } else { - inputText = "" - } + inputText = formatBalanceInput(fromBalance, fromToken?.isWeb3 == true) if (inputText.isNotBlank()) { val fromAmount = inputText.toBigDecimalOrNull() val standardPrice = limitPriceText.toBigDecimalOrNull() @@ -347,12 +362,7 @@ fun LimitOrderContent( }, onDeposit = null, onMax = { - val balance = toBalance?.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (balance > BigDecimal.ZERO) { - outputText = balance.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString() - } else { - outputText = "" - } + outputText = formatBalanceInput(toBalance, toToken?.isWeb3 == true) if (outputText.isNotBlank()) { val toAmount = outputText.toBigDecimalOrNull() val standardPrice = limitPriceText.toBigDecimalOrNull() @@ -405,6 +415,7 @@ fun LimitOrderContent( val isPriceValid = limitPriceText.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true val isOutputValid = outputText.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true val isEnabled = isInputValid && isPriceValid && isOutputValid && checkBalance == true && toToken != null + val isBusy = isSubmitting || reviewing Button( modifier = Modifier .fillMaxWidth() @@ -412,7 +423,7 @@ fun LimitOrderContent( onClick = { keyboardController?.hide() focusManager.clearFocus() - if (isButtonEnabled && toToken != null) { + if (isButtonEnabled && !isBusy && toToken != null) { isButtonEnabled = false isSubmitting = true keyboardController?.hide() @@ -429,8 +440,8 @@ fun LimitOrderContent( viewModel.getAddressesByChainId(Web3Signer.currentWalletId, toTokenValue.chain.chainId)?.destination } else null - val scaledAmount = inputText.toBigDecimalOrNull()?.setScale(8, RoundingMode.DOWN)?.stripTrailingZeros()?.toPlainString() ?: inputText - val scaledExpected = outputText.toBigDecimalOrNull()?.setScale(8, RoundingMode.DOWN)?.stripTrailingZeros()?.toPlainString() ?: outputText + val scaledAmount = formatLimitOrderAmount(inputText, fromTokenValue.isWeb3) + val scaledExpected = formatLimitOrderAmount(outputText, toTokenValue.isWeb3) val request = LimitOrderRequest( walletId = walletId, assetId = fromTokenValue.assetId, @@ -456,7 +467,7 @@ fun LimitOrderContent( } } }, - enabled = isEnabled, + enabled = isEnabled && !isBusy, colors = ButtonDefaults.outlinedButtonColors( backgroundColor = if (isEnabled) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGrayLight, ), @@ -474,7 +485,7 @@ fun LimitOrderContent( .height(24.dp), contentAlignment = Alignment.Center ) { - if (isSubmitting) { + if (isBusy) { CircularProgressIndicator( modifier = Modifier .width(18.dp) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt index f5b157b22b..7f9116c513 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt @@ -792,17 +792,18 @@ class TradeFragment : BaseFragment() { return } } - if (!ensureWeb3FeeSufficient( + val feeCheckResult = ensureWeb3FeeSufficient( from = from, destination = order.depositDestination, amount = order.order.payAmount, - allowGasless = false, - ).isSufficient - ) { + allowGasless = true, + includeSwapPreviewData = true, + ) + if (!feeCheckResult.isSufficient) { reviewing = false return } - LimitTransferBottomSheetDialogFragment.newInstance(order, from, to, senderWalletId).apply { + LimitTransferBottomSheetDialogFragment.newInstance(order, from, to, senderWalletId, feeCheckResult.swapPreviewData).apply { setOnDone { initialAmount = null lastOrderTime = System.currentTimeMillis() @@ -848,7 +849,7 @@ class TradeFragment : BaseFragment() { assetId = token.assetId, amount = amount, feeAssetId = token.assetId, - feeAmount = BigDecimal.ZERO.toPlainString(), + feeAmount = null, chainId = token.chainId, ) ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt index 6fd26ebf41..df71ae1d17 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt @@ -180,6 +180,7 @@ fun TradePage( inMixin, initialAmount, lastOrderTime, + reviewing, { isReverse, type -> onSelectToken(isReverse, type, true) }, onLimitReview, onDeposit, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt index ddd63463c1..5611a04e2e 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt @@ -520,7 +520,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } lifecycleScope.launch( CoroutineExceptionHandler { _, error -> - ErrorHandler.Companion.handleError(error) + ErrorHandler.handleError(error) alertDialog.dismiss() }, ) { @@ -751,16 +751,18 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } TransferType.BIOMETRIC_ITEM -> { assetBiometricItem?.let { item -> - when { - item is WithdrawBiometricItem -> { + when (item) { + is WithdrawBiometricItem -> { // isFeeWaived todo check is my wallet titleView.setLabel(getString(R.string.Send_To_Title), addressLabel, "") } - item is AddressTransferBiometricItem -> { + + is AddressTransferBiometricItem -> { titleView.setLabel(getString(R.string.Send_To_Title), null, (if (toAddress == null) item.address else "$toAddress${addressTag?.let { ":$it" } ?: ""}").formatPublicKey(16)) renderTitle(toAddress ?: item.address, addressTag) } - item is TransferBiometricItem -> { + + is TransferBiometricItem -> { titleView.setSubTitle(getString(R.string.Send_To_Title), item.users) { showUserList(item.users) } @@ -1062,7 +1064,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC if (isAdded) { currentNote = note binding.contentTextView.text = - if (note.isNotEmpty()) note else getString(R.string.add_a_note) + note.ifEmpty { getString(R.string.add_a_note) } } } } @@ -1478,14 +1480,14 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private suspend fun refreshFee() { - when { - transferType == TransferType.ADDRESS -> { + when (transferType) { + TransferType.ADDRESS -> { refreshFee(token!!) } - transferType == TransferType.WEB3 -> { + TransferType.WEB3 -> { refreshWeb3Fees(web3Token!!) } - transferType == TransferType.BIOMETRIC_ITEM && assetBiometricItem is WithdrawBiometricItem -> { + TransferType.BIOMETRIC_ITEM if assetBiometricItem is WithdrawBiometricItem -> { refreshFee(token!!) } else -> { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/LimitTransferBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/LimitTransferBottomSheetDialogFragment.kt index 1ef34d477a..93405055e0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/LimitTransferBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/LimitTransferBottomSheetDialogFragment.kt @@ -9,6 +9,7 @@ import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import com.google.gson.JsonElement import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -30,6 +31,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,8 +61,11 @@ import one.mixin.android.R import one.mixin.android.api.MixinResponse import one.mixin.android.api.ResponseError import one.mixin.android.api.request.web3.EstimateFeeRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.request.web3.Web3RawTransactionRequest import one.mixin.android.api.response.CreateLimitOrderResponse +import one.mixin.android.api.response.web3.EthGaslessTxPayload +import one.mixin.android.api.response.web3.GaslessTxResponse import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme @@ -74,8 +79,11 @@ import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.nowInUtc +import one.mixin.android.extension.notNullWithElse import one.mixin.android.extension.putLong import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.stripAmountZero import one.mixin.android.extension.updatePinCheck import one.mixin.android.extension.withArgs import one.mixin.android.session.Session @@ -96,8 +104,11 @@ import one.mixin.android.ui.home.web3.Web3ViewModel import one.mixin.android.ui.home.web3.components.ActionBottom import one.mixin.android.ui.tip.wc.compose.ItemContent import one.mixin.android.ui.tip.wc.compose.ItemWalletContent +import one.mixin.android.ui.tip.wc.sessionrequest.FeeInfo import one.mixin.android.ui.url.UrlInterpreterActivity +import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.ErrorHandler +import one.mixin.android.util.GsonHelper import one.mixin.android.util.SystemUIManager import one.mixin.android.widget.components.MixinButton import one.mixin.android.util.analytics.AnalyticsTracker @@ -105,10 +116,13 @@ import one.mixin.android.util.getMixinErrorStringByCode import one.mixin.android.util.reportException import one.mixin.android.util.tickerFlow import one.mixin.android.vo.User +import one.mixin.android.vo.safe.TokenItem import one.mixin.android.vo.toUser import one.mixin.android.web3.Rpc import one.mixin.android.web3.js.JsSignMessage import one.mixin.android.web3.js.Web3Signer +import org.sol4k.Base58 +import org.sol4k.Constants.SIGNATURE_LENGTH import org.sol4kt.VersionedTransactionCompat import org.web3j.utils.Convert import org.web3j.utils.Numeric @@ -126,6 +140,7 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag companion object { const val TAG = "LimitTransferBottomSheetDialogFragment" + private const val GASLESS_EIP7702_AUTHORIZED_ADDRESS = "0xe6cae83bde06e4c305530e199d7217f42808555b" private const val ARGS_LINK = "args_link" private const val ARGS_IN_AMOUNT = "args_in_amount" private const val ARGS_IN_ASSET = "args_in_asset" @@ -135,8 +150,15 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private const val ARGS_DEPOSIT_DESTINATION = "args_deposit_destination" private const val ARGS_WALLET_ID = "args_wallet_id" private const val ARGS_DISPLAY_USER_ID = "args_display_user_id" + private const val ARGS_PREVIEW_DATA = "args_preview_data" - fun newInstance(order: CreateLimitOrderResponse, from: SwapToken, to: SwapToken, walletId: String): LimitTransferBottomSheetDialogFragment { + fun newInstance( + order: CreateLimitOrderResponse, + from: SwapToken, + to: SwapToken, + walletId: String, + previewData: SwapTransferPreviewData? = null, + ): LimitTransferBottomSheetDialogFragment { return LimitTransferBottomSheetDialogFragment().withArgs { putString(ARGS_LINK, order.tx) putString(ARGS_DEPOSIT_DESTINATION, order.depositDestination) @@ -147,6 +169,7 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag putString(ARGS_EXPIRED_AT, order.order.expiredAt) putString(ARGS_WALLET_ID, walletId) putString(ARGS_DISPLAY_USER_ID, order.displayUserId) + putParcelable(ARGS_PREVIEW_DATA, previewData) } } } @@ -198,6 +221,7 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private val depositDestination by lazy { requireArguments().getString(ARGS_DEPOSIT_DESTINATION) } private val expiredAt: String? by lazy { requireArguments().getString(ARGS_EXPIRED_AT) } private val displayUserId by lazy { requireArguments().getString(ARGS_DISPLAY_USER_ID) } + private val previewData by lazy { requireArguments().getParcelableCompat(ARGS_PREVIEW_DATA, SwapTransferPreviewData::class.java) } private var step by mutableStateOf(Step.Pending) private var parsedLink: ParsedLink? = null @@ -205,14 +229,18 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private var errorInfo: String? by mutableStateOf(null) private var web3Transaction: JsSignMessage? by mutableStateOf(null) + private var gaslessPrepareResponse: GaslessTxResponse? by mutableStateOf(null) private var tipGas: TipGas? by mutableStateOf(null) private var solanaFee: BigDecimal? by mutableStateOf(null) private var solanaTx: VersionedTransactionCompat? by mutableStateOf(null) + private var asset: TokenItem? by mutableStateOf(null) private var chainToken: Web3TokenItem? by mutableStateOf(null) private var token: Web3TokenItem? by mutableStateOf(null) private var insufficientGas by mutableStateOf(false) private var walletName by mutableStateOf(null) private var isWeb3 by mutableStateOf(false) + private var senderAddress: String? by mutableStateOf(null) + private var walletDisplayInfo by mutableStateOf?>(null) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val view = super.onCreateView(inflater, container, savedInstanceState) @@ -231,7 +259,7 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag .fillMaxHeight() .background(MixinAppTheme.colors.background), ) { - // No wallet label for mixin internal transfer + WalletLabel(walletName = walletName, isWeb3 = isWeb3) Column( modifier = Modifier @@ -356,15 +384,69 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag val expiryLabel = remember(expiredAt) { mapExpiryToLabel(expiredAt) } ItemContent(title = stringResource(id = R.string.expiry).uppercase(), subTitle = stringResource(id = expiryLabel)) Box(modifier = Modifier.height(20.dp)) - ItemUserContent(title = stringResource(id = R.string.Receiver).uppercase(), user = receiver, address = null) + if (isWeb3) { + val chainId = token?.chainId ?: inAsset.chain.chainId + val isGaslessPrepared = web3Transaction?.type == JsSignMessage.TYPE_GASLESS_TRANSFER && gaslessPrepareResponse != null + val previewFeeAmount = previewData?.feeAmount?.toBigDecimalOrNull() + val previewFeeUsd = previewData?.feeUsd?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val shouldShowFeeLoading = + !isGaslessPrepared && ( + (chainId == Constants.ChainId.SOLANA_CHAIN_ID && solanaFee == null) || + (chainId != Constants.ChainId.SOLANA_CHAIN_ID && tipGas == null) + ) + if (tipGas != null || solanaFee != null || (isGaslessPrepared && previewFeeAmount != null)) { + val transaction = web3Transaction?.wcEthereumTransaction + val fee = solanaFee?.stripTrailingZeros() + ?: tipGas?.displayValue(transaction?.maxFeePerGas) + ?: previewFeeAmount + ?: BigDecimal.ZERO + val feeSymbol = previewData?.feeSymbol ?: asset?.symbol.orEmpty() + val feeUsd = if (previewFeeUsd > BigDecimal.ZERO && tipGas == null && solanaFee == null) { + previewFeeUsd + } else { + fee.multiply(asset?.priceUsd?.toBigDecimal() ?: BigDecimal.ZERO) + } + if (fee == BigDecimal.ZERO) { + FeeInfo( + amount = "$fee", + fee = feeUsd, + ) + } else { + FeeInfo( + amount = "$fee $feeSymbol", + fee = feeUsd, + gasPrice = previewData?.gasPrice ?: tipGas?.displayGas(transaction?.maxFeePerGas)?.toPlainString(), + ) + } + } else { + FeeInfo("0", BigDecimal.ZERO, isLoading = shouldShowFeeLoading) + } + Box(modifier = Modifier.height(20.dp)) + } + if (isWeb3) { + val account = senderAddress ?: "" + LaunchedEffect(account) { + if (account.isBlank()) { + walletDisplayInfo = null + } else { + walletDisplayInfo = try { + web3ViewModel.checkAddressAndGetDisplayName(account, null, inAsset.chain.chainId) + } catch (e: Exception) { + null + } + } + } + walletDisplayInfo.notNullWithElse({ walletDisplayInfo -> + val (displayName, _, _) = walletDisplayInfo + ItemContent(title = stringResource(id = R.string.Sender).uppercase(), subTitle = account, displayName) + }, { + ItemContent(title = stringResource(id = R.string.Sender).uppercase(), subTitle = account) + }) + } else { + ItemWalletContent(title = stringResource(id = R.string.Sender).uppercase(), fontSize = 16.sp) + } Box(modifier = Modifier.height(20.dp)) - val isPrivacyWallet = senderWalletId == Session.getAccountId() - ItemWalletContent( - title = stringResource(id = R.string.Sender).uppercase(), - fontSize = 16.sp, - walletId = if (isPrivacyWallet) null else senderWalletId, - walletName = if (isPrivacyWallet) null else walletName - ) + ItemUserContent(title = stringResource(id = R.string.Receiver).uppercase(), user = receiver, address = null) if (parsedLink?.memo.isNullOrBlank().not()) { Box(modifier = Modifier.height(20.dp)) ItemContent(title = stringResource(id = R.string.Memo).uppercase(), subTitle = parsedLink?.memo ?: stringResource(id = R.string.None)) @@ -448,6 +530,15 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag if (isWeb3 && web3Transaction != null) { try { when (web3Transaction!!.type) { + JsSignMessage.TYPE_GASLESS_TRANSFER -> { + submitGaslessTransfer(pin) + defaultSharedPreferences.putLong(Constants.BIOMETRIC_PIN_CHECK, System.currentTimeMillis()) + context?.updatePinCheck() + step = Step.Done + val wallet = if (inAsset.isWeb3) AnalyticsTracker.TradeWallet.WEB3 else AnalyticsTracker.TradeWallet.MAIN + AnalyticsTracker.trackTradeEnd(wallet, inAmount, inAsset.price) + } + JsSignMessage.TYPE_RAW_TRANSACTION -> { val priv = bottomViewModel.getWeb3Priv(requireContext(), pin, inAsset.chain.chainId) val tx = Web3Signer.signSolanaTransaction(priv, requireNotNull(solanaTx) { "required solana tx can not be null" }) { @@ -607,17 +698,23 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag val token = bottomViewModel.web3TokenItemById(Web3Signer.currentWalletId, inAsset.assetId) if (token != null) { try { - val transaction = token.buildTransaction( - rpc, - if (token.chainId == Constants.ChainId.Solana) Web3Signer.solanaAddress else Web3Signer.evmAddress, - depositDestination!!, - inAmount.toPlainString() - ) - web3Transaction = transaction this@LimitTransferBottomSheetDialogFragment.token = token - - val chain = token.getChainFromName() - refreshEstimatedGasAndAsset(chain) + chainToken = bottomViewModel.web3TokenItemById(Web3Signer.currentWalletId, token.chainId) + asset = chainToken?.toTokenItem() + if (previewData != null) { + applyPreviewData(previewData!!, token) + } else { + senderAddress = resolveSenderAddress(token) + val transaction = token.buildTransaction( + rpc, + senderAddress!!, + depositDestination!!, + inAmount.toPlainString() + ) + web3Transaction = transaction + val chain = token.getChainFromName() + refreshEstimatedGasAndAsset(chain) + } } catch (e: Exception) { Timber.e(e, "Failed to build transaction") errorInfo = e.message @@ -639,6 +736,7 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag tickerFlow(15.seconds) .onEach { try { + asset = bottomViewModel.refreshAsset(assetId) tipGas = withContext(Dispatchers.IO) { val r = bottomViewModel.estimateFee( EstimateFeeRequest( @@ -689,12 +787,187 @@ class LimitTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } solanaFee = solanaTx?.calcFee(tx.message.accounts[0].toBase58()) } + asset = bottomViewModel.refreshAsset(Constants.ChainId.Solana) } catch (e: Exception) { handleException(e) } }.launchIn(lifecycleScope) } + private fun applyPreviewData( + previewData: SwapTransferPreviewData, + token: Web3TokenItem, + ) { + senderAddress = previewData.senderAddress + web3Transaction = previewData.web3Transaction + gaslessPrepareResponse = previewData.gaslessPrepareResponseJson?.let { + GsonHelper.customGson.fromJson(it, GaslessTxResponse::class.java) + } + + val previewFee = previewData.feeAmount.toBigDecimalOrNull() + when (token.chainId) { + Constants.ChainId.SOLANA_CHAIN_ID -> { + solanaFee = previewFee + previewData.web3Transaction.data?.let { + solanaTx = VersionedTransactionCompat.from(it) + } + } + else -> { + tipGas = previewData.toTipGas(token.chainId) + } + } + } + + private suspend fun resolveSenderAddress(token: Web3TokenItem): String { + return if (token.chainId == Constants.ChainId.Solana) { + Web3Signer.solanaAddress + } else if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { + val btcAddress = web3ViewModel.getAddressesByChainId(Web3Signer.currentWalletId, Constants.ChainId.BITCOIN_CHAIN_ID) + requireNotNull(btcAddress?.destination) { "btc address not found" } + } else { + Web3Signer.evmAddress + } + } + + private fun SwapTransferPreviewData.toTipGas(chainId: String): TipGas? { + val gasLimit = tipGasLimit?.toBigIntegerOrNull() ?: return null + val maxFeePerGas = tipGasMaxFeePerGas?.toBigIntegerOrNull() ?: return null + val maxPriorityFeePerGas = tipGasMaxPriorityFeePerGas?.toBigIntegerOrNull() ?: return null + return TipGas( + assetId = chainId, + gasLimit = gasLimit, + maxFeePerGas = maxFeePerGas, + maxPriorityFeePerGas = maxPriorityFeePerGas, + ) + } + + private suspend fun submitGaslessTransfer(pin: String) { + val transferToken = requireNotNull(token) { "required web3 token can not be null" } + val fromAddress = if (transferToken.chainId == Constants.ChainId.Solana) Web3Signer.solanaAddress else Web3Signer.evmAddress + val toAddress = requireNotNull(depositDestination) { "deposit destination can not be null" } + val amount = inAmount.stripTrailingZeros().toPlainString() + val preparedResponse = requireNotNull(gaslessPrepareResponse) { "gasless prepare response is required" } + val privateKey = web3ViewModel.getWeb3Priv(requireContext(), pin, transferToken.chainId) + when (transferToken.chainId) { + Constants.ChainId.SOLANA_CHAIN_ID -> submitSolanaGaslessTransfer( + token = transferToken, + amount = amount, + fromAddress = fromAddress, + toAddress = toAddress, + payload = preparedResponse.payload, + privateKey = privateKey, + ) + in Constants.Web3EvmChainIds -> submitEvmGaslessTransfer( + token = transferToken, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + chainId = preparedResponse.chainId, + payload = preparedResponse.payload, + privateKey = privateKey, + ) + else -> throw IllegalArgumentException("Gasless is not supported for ${transferToken.chainId}") + } + } + + private suspend fun submitSolanaGaslessTransfer( + token: Web3TokenItem, + amount: String, + fromAddress: String, + toAddress: String, + payload: JsonElement, + privateKey: ByteArray, + ) { + val rawPayload = payload.takeIf { it.isJsonPrimitive }?.asString + ?: throw IllegalStateException("Gasless payload is not a Solana base64 transaction") + val tx = VersionedTransactionCompat.from(rawPayload) + val signedTx = Web3Signer.signSolanaTransaction(privateKey, tx) { + rpc.getLatestBlockhash() ?: throw IllegalArgumentException("failed to get blockhash") + } + if (!signedTx.allSignerSigned()) { + throw IllegalStateException("Gasless Solana transaction is not fully signed") + } + val rawTx = signedTx.serialize().base64Encode() + val txHash = signedTx.signatures.firstOrNull { signature -> + signature != Base58.encode(ByteArray(SIGNATURE_LENGTH)) + } ?: throw IllegalStateException("Gasless Solana transaction signature is missing") + val now = nowInUtc() + web3ViewModel.insertSignedPendingTransaction( + hash = txHash, + chainId = Constants.ChainId.Solana, + account = fromAddress, + assetId = token.assetId, + amount = amount.stripAmountZero(), + fee = "0", + to = toAddress, + raw = rawTx, + createdAt = now, + updatedAt = now, + ) + val response = web3ViewModel.postRawTx(rawTx, Constants.ChainId.Solana, fromAddress, toAddress, token.assetId) + if (!response.isSuccess) { + throw IllegalStateException(response.errorDescription) + } + } + + private suspend fun submitEvmGaslessTransfer( + token: Web3TokenItem, + fromAddress: String, + toAddress: String, + amount: String, + chainId: String, + payload: JsonElement, + privateKey: ByteArray, + ) { + if (!payload.isJsonObject) { + throw IllegalStateException("Gasless payload is not an EVM object payload") + } + val ethPayload = GsonHelper.customGson.fromJson(payload, EthGaslessTxPayload::class.java) + ?: throw IllegalStateException("Failed to parse gasless EVM payload") + val userOpSignature = Web3Signer.signEthMessage( + priv = privateKey, + message = ethPayload.signing.userOperation.message, + type = JsSignMessage.TYPE_GASLESS_TRANSFER, + ) + val eip7702AuthSignature = ethPayload.signing.eip7702Auth + ?.takeIf { it.required } + ?.let { auth -> + if (!auth.address.equals(GASLESS_EIP7702_AUTHORIZED_ADDRESS, ignoreCase = true)) { + throw IllegalArgumentException("Unsupported EIP-7702 auth target") + } + Web3Signer.signEthMessage( + priv = privateKey, + message = auth.message, + type = JsSignMessage.TYPE_GASLESS_TRANSFER, + ) + } + val response = web3ViewModel.submitGaslessTx( + SubmitGaslessTxRequest( + chainId = chainId, + payload = payload, + userOpSignature = userOpSignature, + eip7702AuthSignature = eip7702AuthSignature, + ), + ) + if (!response.isSuccess) { + throw IllegalStateException(response.errorDescription) + } + val sponsorTxId = response.data?.sponsorTxId ?: throw IllegalStateException("Missing sponsor tx id") + val now = nowInUtc() + web3ViewModel.insertGaslessPendingTransaction( + sponsorTxId = sponsorTxId, + chainId = chainId, + account = fromAddress, + assetId = token.assetId, + amount = amount.stripAmountZero(), + fee = "0", + to = toAddress, + nonce = ethPayload.userOperation.nonce, + createdAt = now, + updatedAt = now, + ) + } + private fun checkGas( web3Token: Web3TokenItem?, chainToken: Web3TokenItem?,