diff --git a/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt b/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt index 9fba6d79a4..7c8fef1a8f 100644 --- a/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt +++ b/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt @@ -22,6 +22,7 @@ import one.mixin.android.tip.wc.internal.WCEthereumSignMessage import one.mixin.android.tip.wc.internal.WCEthereumTransaction import one.mixin.android.tip.wc.internal.WalletConnectException import one.mixin.android.tip.wc.internal.WalletConnectAddresses +import one.mixin.android.tip.wc.internal.WalletConnectPairingErrors import one.mixin.android.tip.wc.internal.WcInstruction import one.mixin.android.tip.wc.internal.WcInstructionDeserializer import one.mixin.android.tip.wc.internal.WcBitcoinAccountAddress @@ -73,6 +74,8 @@ import java.util.concurrent.TimeUnit object WalletConnectV2 : WalletConnect() { const val TAG = "WalletConnectV2" + internal val pairingErrors = WalletConnectPairingErrors() + private const val CHAIN_TYPE_ETH: String = "eth" private const val CHAIN_TYPE_POLYGON: String = "polygon" private const val CHAIN_TYPE_BSC: String = "bsc" @@ -136,7 +139,7 @@ object WalletConnectV2 : WalletConnect() { override fun onError(error: Wallet.Model.Error) { Timber.d("$TAG onError $error") - // RxBus.publish(WCErrorEvent(WCError(error.throwable))) + pairingErrors.report(error.throwable) } override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) { diff --git a/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrors.kt b/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrors.kt new file mode 100644 index 0000000000..3335ffdd3f --- /dev/null +++ b/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrors.kt @@ -0,0 +1,16 @@ +package one.mixin.android.tip.wc.internal + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +internal class WalletConnectPairingErrors { + private val errors = MutableSharedFlow(extraBufferCapacity = 1) + + fun report(error: Throwable) { + errors.tryEmit(error) + } + + suspend fun await(timeoutMillis: Long = 30_000): Throwable? = + withTimeoutOrNull(timeoutMillis) { errors.first() } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt index 73a190b7b6..fd1c9a4e22 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt @@ -16,15 +16,17 @@ import androidx.fragment.app.FragmentActivity import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.response.web3.ParsedTx import one.mixin.android.api.response.web3.WalletOutput import one.mixin.android.db.web3.vo.Web3TokenItem @@ -41,7 +43,6 @@ import one.mixin.android.extension.toast import one.mixin.android.extension.withArgs import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.tip.wc.internal.TipGas -import one.mixin.android.tip.wc.internal.buildTipGas import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment import one.mixin.android.ui.common.biometric.BiometricInfo @@ -66,7 +67,6 @@ import one.mixin.android.web3.js.JsSignMessage import one.mixin.android.web3.js.SolanaTxSource import one.mixin.android.web3.js.Web3Signer import one.mixin.android.web3.js.WalletErrorCode -import one.mixin.android.web3.js.throwIfAnyMaliciousInstruction import one.mixin.android.web3.send.UtxoTransactionSigner import org.json.JSONObject import org.sol4k.Base58 @@ -75,8 +75,6 @@ import org.sol4k.exception.RpcException import org.sol4kt.SignInInput import org.sol4kt.VersionedTransactionCompat import org.web3j.crypto.Hash -import org.web3j.utils.Convert -import org.web3j.utils.Numeric import timber.log.Timber import java.math.BigDecimal import javax.inject.Inject @@ -190,6 +188,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private var parsedTx: ParsedTx? by mutableStateOf(null) private var solanaSignInInput: SignInInput? by mutableStateOf(null) private var requestSettled = false + private var transactionRefreshJob: Job? = null @Inject lateinit var rpc: Rpc @@ -334,39 +333,23 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag val transaction = signMessage.wcEthereumTransaction ?: return val cachedTipGas = tipGas var useCachedTipGas = cachedTipGas != null - tickerFlow(15.seconds) + transactionRefreshJob = tickerFlow(15.seconds) .onEach { asset = viewModel.refreshAsset(assetId) try { - val currentTipGas = if (useCachedTipGas) { - useCachedTipGas = false - cachedTipGas - } else { - withContext(Dispatchers.IO) { - val r = runCatching { - viewModel.estimateFee( - EstimateFeeRequest( - assetId, - null, - transaction.data, - transaction.from, - transaction.to, - transaction.value, - ) - ) - }.getOrNull() - if (r?.isSuccess != true) { - step = Step.Error - ErrorHandler.handleMixinError(r?.errorCode ?: 0, r?.errorDescription ?: "") - return@withContext null - } - buildTipGas(chain.chainId, r.data!!) - } - } ?: return@onEach + val result = requireNotNull( + viewModel.preflightTransaction( + signMessage, chain, token?.walletId ?: Web3Signer.currentWalletId, + if (useCachedTipGas) cachedTipGas else null, + ), + ) + useCachedTipGas = false + val currentTipGas = requireNotNull(result.tipGas) tipGas = currentTipGas - insufficientGas = checkGas(token, chainToken, currentTipGas, transaction.value, transaction.maxFeePerGas) + insufficientGas = result.insufficientBalance if (insufficientGas) { - handleException(IllegalArgumentException(requireContext().getString(R.string.insufficient_gas, chainToken?.symbol ?: currentChain.symbol))) + handleException(IllegalArgumentException(requireContext().getString(R.string.insufficient_gas, result.balance.token.symbol))) + return@onEach } val hex = Web3Signer.ethPreviewTransaction( Web3Signer.evmAddress, @@ -380,15 +363,18 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag if (parsedTx == null) { parsedTx = viewModel.simulateWeb3Tx(hex, assetId, from = Web3Signer.evmAddress, toAddress) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Timber.e(e) + tipGas = null + handleException(e) } } - .launchIn(lifecycleScope) + .launchIn(viewLifecycleOwner.lifecycleScope) } private fun refreshSolana() { - tickerFlow(15.seconds) + transactionRefreshJob = tickerFlow(15.seconds) .onEach { try { if (signMessage.type == JsSignMessage.TYPE_RAW_TRANSACTION) { @@ -404,20 +390,31 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag if (parsedTx == null) { parsedTx = viewModel.simulateWeb3Tx(tx.serialize().base64Encode(), Constants.ChainId.Solana, Web3Signer.solanaAddress, toAddress) } - tx.throwIfAnyMaliciousInstruction() + val result = viewModel.preflightTransaction( + JsSignMessage(signMessage.callbackId, JsSignMessage.TYPE_RAW_TRANSACTION, data = tx.serialize().base64Encode()), + Chain.Solana, + token?.walletId ?: Web3Signer.currentWalletId, + ) + insufficientGas = result?.insufficientBalance == true + if (result?.insufficientBalance == true) { + handleException(IllegalArgumentException(getString(R.string.insufficient_gas, result.balance.token.symbol))) + } } else if (signMessage.type == JsSignMessage.TYPE_SIGN_IN) { solanaSignInInput = SignInInput.from(signMessage.data ?: "", Web3Signer.address) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { handleException(e) } asset = viewModel.refreshAsset(Chain.Solana.assetId) - }.launchIn(lifecycleScope) + }.launchIn(viewLifecycleOwner.lifecycleScope) } private fun doAfterPinComplete(pin: String) = lifecycleScope.launch(Dispatchers.IO) { try { + transactionRefreshJob?.cancelAndJoin() step = Step.Loading errorInfo = null customPinAction?.let { action -> @@ -431,6 +428,17 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } return@launch } + val preflightMessage = if (signMessage.type == JsSignMessage.TYPE_RAW_TRANSACTION && solanaTx != null) { + JsSignMessage(signMessage.callbackId, JsSignMessage.TYPE_RAW_TRANSACTION, data = solanaTx!!.serialize().base64Encode()) + } else { + signMessage + } + val preflight = viewModel.preflightTransaction(preflightMessage, currentChain, token?.walletId ?: Web3Signer.currentWalletId, tipGas) + if (preflight?.insufficientBalance == true) { + insufficientGas = true + throw IllegalArgumentException(getString(R.string.insufficient_gas, preflight.balance.token.symbol)) + } + preflight?.tipGas?.let { tipGas = it } if (signMessage.type == JsSignMessage.TYPE_UTXO_TRANSACTION) { val rawHex = signMessage.data ?: throw IllegalArgumentException("empty UTXO transaction hex") val chainId = utxoChainId @@ -487,6 +495,8 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag Constants.BIOMETRIC_PIN_CHECK, System.currentTimeMillis(), ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { settleError(WalletErrorCode.INTERNAL_ERROR, e.message ?: "Signing failed") handleException(e) @@ -494,6 +504,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } override fun onDismiss(dialog: DialogInterface) { + transactionRefreshJob?.cancel() super.onDismiss(dialog) onDismissAction?.invoke(step == Step.Done) } @@ -514,31 +525,6 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag return tx } - private fun checkGas( - web3Token: Web3TokenItem?, - chainToken: Web3TokenItem?, - tipGas: TipGas?, - value: String?, - maxFeePerGas: String?, - ): Boolean { - return if (web3Token != null) { - if (chainToken == null) { - true - } else if (tipGas != null) { - val maxGas = tipGas.displayValue(maxFeePerGas) ?: BigDecimal.ZERO - if (web3Token.assetId == chainToken.assetId && web3Token.chainId == chainToken.chainId) { - Convert.fromWei(Numeric.decodeQuantity(value ?: "0x0").toBigDecimal(), Convert.Unit.ETHER) + maxGas > BigDecimal(chainToken.balance) - } else { - maxGas > BigDecimal(chainToken.balance) - } - } else { - false - } - } else { - false - } - } - private fun handleException(e: Throwable) { Timber.e(e) val msg = diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetViewModel.kt index 19fe948158..efce7bca99 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetViewModel.kt @@ -20,7 +20,12 @@ import one.mixin.android.repository.TokenRepository import one.mixin.android.repository.UserRepository import one.mixin.android.repository.Web3Repository import one.mixin.android.tip.Tip +import one.mixin.android.tip.wc.internal.Chain +import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.util.ErrorHandler +import one.mixin.android.web3.js.JsSignMessage +import one.mixin.android.web3.js.Web3Signer +import one.mixin.android.web3.preflightDappTransaction import org.sol4k.exception.RpcException import java.math.BigDecimal import javax.inject.Inject @@ -105,7 +110,14 @@ class BrowserWalletBottomSheetViewModel } } - suspend fun estimateFee(request: EstimateFeeRequest) = web3Repository.estimateFee(request) + suspend fun preflightTransaction(message: JsSignMessage, chain: Chain, walletId: String, cachedTipGas: TipGas? = null) = withContext(Dispatchers.IO) { + preflightDappTransaction( + message, chain, if (chain == Chain.Solana) Web3Signer.solanaAddress else Web3Signer.evmAddress, + findToken = { web3Repository.web3TokenItemById(walletId, it) }, + estimateFee = web3Repository::estimateFee, + cachedTipGas = cachedTipGas, + ) + } suspend fun outputsByAddress(address: String, assetId: String): List = withContext(Dispatchers.IO) { web3Repository.outputsByAddress(address, assetId) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt index f0b6aafeef..b4173b50e9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt @@ -2,6 +2,7 @@ package one.mixin.android.ui.home.web3 import android.annotation.SuppressLint import android.app.Dialog +import android.content.DialogInterface import android.os.Bundle import android.view.Gravity import android.view.View @@ -15,12 +16,10 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.response.web3.SwapResponse import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.databinding.FragmentBottomSheetBinding @@ -35,7 +34,6 @@ import one.mixin.android.extension.isNightMode import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WCEthereumTransaction -import one.mixin.android.tip.wc.internal.buildTipGas import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.transfer.TransferWeb3BalanceErrorBottomSheetDialogFragment import one.mixin.android.util.ErrorHandler @@ -51,8 +49,6 @@ import one.mixin.android.web3.js.Web3Signer import one.mixin.android.web3.solanaRecipientAccountState import one.mixin.android.web3.solanaTransferAmountRange import org.sol4kt.VersionedTransactionCompat -import org.web3j.utils.Convert -import org.web3j.utils.Numeric import timber.log.Timber import java.math.BigDecimal import javax.inject.Inject @@ -104,48 +100,35 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { } private suspend fun checkSolanaBalanceOrShowError() { - if (!signMessage.isSolMessage()) { - showBrowserWalletBottomSheet() - return - } - if (signMessage.type != JsSignMessage.TYPE_RAW_TRANSACTION) { - showBrowserWalletBottomSheet() - return - } - val rawTx: String = signMessage.data ?: run { - showError(getString(R.string.Data_error)) - return - } - val tx = runCatching { VersionedTransactionCompat.from(rawTx) }.getOrNull() ?: run { - showError(getString(R.string.Data_error)) - return - } - val requiredFee: BigDecimal = tx.calcFee(Web3Signer.address) - val solAsset = viewModel.web3TokenItemById(Web3Signer.currentWalletId, Chain.Solana.assetId) - val solBalance: BigDecimal = solAsset?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (solBalance >= requiredFee) { - showBrowserWalletBottomSheet() - return - } - val solTokenItem: Web3TokenItem? = chainToken ?: viewModel.web3TokenItemById(Web3Signer.currentWalletId, Chain.Solana.assetId) - if (solTokenItem == null) { - showError(getString(R.string.Data_error)) - return + try { + val result = viewModel.preflightTransaction(signMessage, Chain.Solana, Web3Signer.currentWalletId) + if (result?.insufficientBalance == true) { + showBalanceError(result.balance) + } else { + showBrowserWalletBottomSheet() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + showError(ErrorHandler.getErrorMessage(e)) } - TransferWeb3BalanceErrorBottomSheetDialogFragment - .newInstance( - Web3TokenFeeItem( - solTokenItem, - BigDecimal.ZERO, - requiredFee - ) - ).showNow( - parentFragmentManager, - TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG - ) + } + + private fun showBalanceError(balance: Web3TokenFeeItem) { + TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance(balance) + .showNow(parentFragmentManager, TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG) + handedOff = true + onReject?.invoke() dismiss() } + private var handedOff = false + + override fun onDismiss(dialog: DialogInterface) { + if (!handedOff) onReject?.invoke() + super.onDismiss(dialog) + } + private val binding by viewBinding(FragmentBottomSheetBinding::inflate) private lateinit var contentView: View @@ -295,6 +278,7 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { } fragment.show(requireActivity().supportFragmentManager, BrowserWalletBottomSheetDialogFragment.TAG) } + handedOff = true dismissAllowingStateLoss() } @@ -312,78 +296,25 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { } return } - val chainId = chain.getWeb3ChainId() if (transaction == null) { - Timber.e("Transaction is null") showBrowserWalletBottomSheet() return } try { - val tipGas = withContext(Dispatchers.IO) { - val r = runCatching { - viewModel.estimateFee( - EstimateFeeRequest( - chainId, - null, - transaction.data, - transaction.from, - transaction.to, - transaction.value, - ) - ) - }.getOrNull() - if (r?.isSuccess != true) { - ErrorHandler.handleMixinError(r?.errorCode ?: 0, r?.errorDescription ?: "") - return@withContext null - } - buildTipGas(chain.chainId, r.data!!) - } - if (tipGas == null) { - Timber.e("Failed to estimate gas for chain: ${chain.chainId}") - showBrowserWalletBottomSheet() - return - } - val insufficientGas = - checkGas(token, chainId = chainId, tipGas, transaction.value, transaction.maxFeePerGas) - if (insufficientGas) { - val c = chainToken ?: viewModel.web3TokenItemById(token?.walletId ?: Web3Signer.currentWalletId, chainId) - if (c == null) { - Timber.e("Insufficient gas for chain: ${chain.chainId}") - showError(getString(R.string.Data_error)) - return - } else if (c.balance.toBigDecimal() <= BigDecimal.ZERO) { - Timber.e("Insufficient gas and zero balance for chain: ${c.assetId}") - TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance( - Web3TokenFeeItem( - c, - BigDecimal.ZERO, - tipGas.displayValue(transaction.maxFeePerGas) ?: BigDecimal.ZERO - ) - ).showNow( - parentFragmentManager, - TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG - ) - dismiss() - } else { - val fee = tipGas.displayValue(transaction.maxFeePerGas) ?: BigDecimal.ZERO - val amount = transaction.getMainTokenAmount() - Timber.e("Insufficient gas for chain: ${c.assetId}, fee: $fee, amount: $amount") - TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance( - Web3TokenFeeItem( - c, - amount, - fee - ) - ).showNow( - parentFragmentManager, - TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG - ) - dismiss() - } + val result = requireNotNull( + viewModel.preflightTransaction( + JsSignMessage(0, JsSignMessage.TYPE_TRANSACTION, wcEthereumTransaction = transaction), + chain, + token?.walletId ?: Web3Signer.currentWalletId, + ), + ) + if (result.insufficientBalance) { + showBalanceError(result.balance) } else { - Timber.e("Sufficient gas for chain: ${chain.chainId}, gas: ${tipGas.maxFeePerGas} ${tipGas.gasLimit}") - showBrowserWalletBottomSheet(tipGas) + showBrowserWalletBottomSheet(result.tipGas) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { showError(ErrorHandler.getErrorMessage(e)) } @@ -499,31 +430,6 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { } } - private suspend fun checkGas( - web3Token: Web3TokenItem?, - chainId: String, - tipGas: TipGas?, - value: String?, - maxFeePerGas: String? - ): Boolean { - val assetId = web3Token?.assetId - val walletId = web3Token?.walletId ?: Web3Signer.currentWalletId - val c = viewModel.web3TokenItemById(walletId, chainId) ?: return true - return if (tipGas != null) { - val maxGas = tipGas.displayValue(maxFeePerGas) ?: BigDecimal.ZERO - if (assetId == c.assetId && assetId == c.chainId) { - Convert.fromWei( - Numeric.decodeQuantity(value ?: "0x0").toBigDecimal(), - Convert.Unit.ETHER - ) + maxGas > BigDecimal(c.balance) - } else { - maxGas > BigDecimal(c.balance) - } - } else { - false - } - } - var onReject: (() -> Unit)? = null var onDestroy: (() -> Unit)? = null var onDone: ((String?) -> Unit)? = null diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt index d45fafbd32..bfe758d07f 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt @@ -13,11 +13,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.fragment.app.FragmentActivity import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.GsonBuilder import com.reown.walletkit.client.Wallet import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -28,7 +31,6 @@ import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.RxBus -import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.dp @@ -41,6 +43,8 @@ import one.mixin.android.extension.withArgs import one.mixin.android.tip.Tip import one.mixin.android.tip.exception.TipNetworkException import one.mixin.android.tip.wc.WCChangeEvent +import one.mixin.android.tip.wc.WCError +import one.mixin.android.tip.wc.WCErrorEvent import one.mixin.android.tip.wc.WalletConnect import one.mixin.android.tip.wc.WalletConnect.RequestType import one.mixin.android.tip.wc.WalletConnectTIP @@ -48,12 +52,13 @@ import one.mixin.android.tip.wc.WalletConnectV2 import one.mixin.android.tip.wc.WalletConnectV2.getProposalChainIds import one.mixin.android.tip.wc.WalletConnectV2.getNamespaceProposal import one.mixin.android.tip.wc.internal.Chain +import one.mixin.android.tip.wc.internal.Method import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WcBitcoinSendTransfer import one.mixin.android.tip.wc.internal.WCEthereumTransaction import one.mixin.android.tip.wc.internal.WalletConnectAddresses import one.mixin.android.tip.wc.internal.WalletConnectException -import one.mixin.android.tip.wc.internal.buildTipGas +import one.mixin.android.tip.wc.internal.WcSolanaTransaction import one.mixin.android.tip.wc.internal.formatProposalAccountText import one.mixin.android.tip.wc.internal.getChain import one.mixin.android.tip.wc.internal.getChainByChainId @@ -70,14 +75,17 @@ import one.mixin.android.ui.tip.wc.sessionproposal.SessionProposalPage import one.mixin.android.ui.tip.wc.sessionrequest.SessionRequestPage import one.mixin.android.ui.url.UrlInterpreterActivity import one.mixin.android.ui.wallet.CrossWalletFeeFreeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.transfer.TransferWeb3BalanceErrorBottomSheetDialogFragment import one.mixin.android.util.ErrorHandler +import one.mixin.android.util.GsonHelper import one.mixin.android.util.SystemUIManager import one.mixin.android.util.reportException import one.mixin.android.util.tickerFlow import one.mixin.android.vo.safe.Token import one.mixin.android.web3.Rpc +import one.mixin.android.web3.js.JsSignMessage +import one.mixin.android.web3.js.SolanaTxSource import one.mixin.android.web3.js.Web3Signer -import one.mixin.android.web3.js.throwIfAnyMaliciousInstruction import org.sol4k.exception.RpcException import org.sol4kt.VersionedTransactionCompat import timber.log.Timber @@ -138,6 +146,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private var account: String by mutableStateOf("") private var signedTransactionData: Any? = null private var estimateGasJob: Job? = null + private var preflightMessage: JsSignMessage? = null @Inject lateinit var rpc: Rpc @@ -205,6 +214,20 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag RequestType.Pay -> Step.Done } checkV2ChainAndParseSignData() + if (requestType == RequestType.Connect) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + val error = WalletConnectV2.pairingErrors.await() + if (step == Step.Connecting && dialog?.isShowing == true) { + RxBus.publish( + WCErrorEvent( + WCError(error ?: IllegalStateException(getString(R.string.error_connection_timeout))), + ), + ) + } + } + } + } } override fun getBottomSheetHeight(view: View): Int { @@ -274,7 +297,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private fun checkV2ChainAndParseSignData() = - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { val topic = this@WalletConnectBottomSheetDialogFragment.topic when (requestType) { @@ -324,19 +347,47 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag this@WalletConnectBottomSheetDialogFragment.signData = signData - val m = signData.signMessage - if (m is WCEthereumTransaction) { - refreshEstimatedGasAndAsset(chain) - } else if (m is VersionedTransactionCompat) { - asset = viewModel.refreshAsset(Chain.Solana.assetId) + val message = signData.signMessage + preflightMessage = when (message) { + is WCEthereumTransaction -> JsSignMessage(signData.requestId, JsSignMessage.TYPE_TRANSACTION, wcEthereumTransaction = message) + is VersionedTransactionCompat -> JsSignMessage( + signData.requestId, + JsSignMessage.TYPE_RAW_TRANSACTION, + data = GsonHelper.customGson.fromJson(sessionRequest.request.params, WcSolanaTransaction::class.java).transaction, + solanaTxSource = SolanaTxSource.WalletConnect, + ) + else -> null + } + if (preflightMessage != null) { + step = Step.Loading try { - m.throwIfAnyMaliciousInstruction() + if (!preflightTransaction()) return@launch + asset = viewModel.refreshAsset(chain.getWeb3ChainId()) + step = Step.Sign + if (message is WCEthereumTransaction) refreshEstimatedGasAndAsset(chain) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { handleException(e) } } } + private suspend fun preflightTransaction(cachedTipGas: TipGas? = null): Boolean { + val message = preflightMessage ?: return true + val result = viewModel.preflightTransaction(message, chain, account, cachedTipGas) ?: return true + tipGas = result.tipGas + signData?.tipGas = result.tipGas + if (result.insufficientBalance) { + stopEstimatedGasRefresh("insufficient_balance") + TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance(result.balance) + .showNow(parentFragmentManager, TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG) + dismiss() + return false + } + return true + } + private fun stopEstimatedGasRefresh(reason: String) { estimateGasJob?.let { Timber.d("$TAG estimateGas stop topic=$topic requestId=${sessionRequest?.request?.id} step=$step reason=$reason") @@ -346,54 +397,29 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private fun refreshEstimatedGasAndAsset(chain: Chain) { - val signData = this.signData ?: return - - val tx = signData.signMessage - if (tx !is WCEthereumTransaction) return - val assetId = chain.getWeb3ChainId() - stopEstimatedGasRefresh("restart") - Timber.d("$TAG estimateGas start topic=$topic requestId=${sessionRequest?.request?.id} step=$step chain=${chain.chainId} assetId=$assetId from=${tx.from} to=${tx.to} value=${tx.value} dataLength=${tx.data?.length ?: 0}") + var cachedTipGas = tipGas estimateGasJob = tickerFlow(15.seconds) .onEach { if (processCompleted || step == Step.Done || step == Step.Sending) { stopEstimatedGasRefresh("step_$step") return@onEach } - Timber.d("$TAG estimateGas tick topic=$topic requestId=${sessionRequest?.request?.id} step=$step chain=${chain.chainId}") - asset = viewModel.refreshAsset(assetId) - if (version == WalletConnect.Version.V2) { - try { - val r = - viewModel.estimateFee( - EstimateFeeRequest( - assetId, - null, - tx.data, - tx.from, - tx.to, - tx.value, - ) - ) - if (r.isSuccess.not()){ - Timber.d("$TAG estimateGas result topic=$topic requestId=${sessionRequest?.request?.id} step=$step success=false errorCode=${r.errorCode} errorDescription=${r.errorDescription}") - step = Step.Error - ErrorHandler.handleMixinError(r.errorCode, r.errorDescription) - tipGas = null - } else { - tipGas = buildTipGas(chain.chainId, r.data!!) - Timber.d("$TAG estimateGas result topic=$topic requestId=${sessionRequest?.request?.id} step=$step success=true gasLimit=${tipGas?.gasLimit} maxFeePerGas=${tipGas?.maxFeePerGas} maxPriorityFeePerGas=${tipGas?.maxPriorityFeePerGas}") - } - if (tipGas != null) { - signData.tipGas = tipGas - } - } catch (e: Exception) { - Timber.e(e, "$TAG estimateGas exception topic=$topic requestId=${sessionRequest?.request?.id} step=$step") - Timber.e(e) - } + try { + val initialTipGas = cachedTipGas + cachedTipGas = null + if (!preflightTransaction(initialTipGas)) return@onEach + asset = viewModel.refreshAsset(chain.getWeb3ChainId()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + tipGas = null + signData?.tipGas = null + handleException(e) + stopEstimatedGasRefresh("error") } } - .launchIn(lifecycleScope) + .launchIn(viewLifecycleOwner.lifecycleScope) } private fun doAfterPinComplete(pin: String) = @@ -401,6 +427,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag stopEstimatedGasRefresh("confirm") step = Step.Loading try { + if (!preflightTransaction(tipGas)) return@launch val error = withContext(Dispatchers.IO) { if (onPinCompleteAction != null) { @@ -417,7 +444,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } if (error == null) { step = - if (isSignEvmTransaction() || isSignSolanaTransaction() || isSendBitcoinTransfer()) { + if (isSendEvmTransaction() || isSignSolanaTransaction() || isSendBitcoinTransfer()) { try { step = Step.Sending val sendError = @@ -454,6 +481,8 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag errorInfo = error step = Step.Error } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { handleException(e) } @@ -570,7 +599,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag ) } - private fun isSignEvmTransaction() = signData != null && signData?.signMessage is WCEthereumTransaction + private fun isSendEvmTransaction() = signData?.sessionRequest?.request?.method == Method.ETHSendTransaction.name private fun isSignSolanaTransaction() = signData != null && signData?.signMessage is VersionedTransactionCompat diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt index 5a65836306..dbd299dd27 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt @@ -16,9 +16,12 @@ import one.mixin.android.tip.Tip import one.mixin.android.tip.wc.WalletConnect import one.mixin.android.tip.wc.WalletConnectV2 import one.mixin.android.tip.wc.internal.Chain +import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WcBitcoinFeeEstimate import one.mixin.android.tip.wc.internal.WcBitcoinSignedTransfer import one.mixin.android.web3.js.Web3Signer +import one.mixin.android.web3.js.JsSignMessage +import one.mixin.android.web3.preflightDappTransaction import org.sol4kt.VersionedTransactionCompat import org.web3j.crypto.Hash import timber.log.Timber @@ -34,7 +37,15 @@ class WalletConnectBottomSheetViewModel private val tip: Tip, ) : ViewModel() { - suspend fun estimateFee(request: EstimateFeeRequest) = web3Repository.estimateFee(request) + suspend fun preflightTransaction(message: JsSignMessage, chain: Chain, account: String, cachedTipGas: TipGas? = null) = withContext(Dispatchers.IO) { + val walletId = Web3Signer.currentWalletId + preflightDappTransaction( + message, chain, account, + findToken = { web3Repository.web3TokenItemById(walletId, it) }, + estimateFee = web3Repository::estimateFee, + cachedTipGas = cachedTipGas, + ) + } suspend fun getV2SessionProposal(topic: String): Wallet.Model.SessionProposal? { return withContext(Dispatchers.IO) { diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt index 33623d8232..63256afea8 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt @@ -48,19 +48,16 @@ import com.reown.walletkit.client.Wallet import one.mixin.android.R import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme -import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.extension.composeDp import one.mixin.android.extension.currencyFormat import one.mixin.android.extension.notNullWithElse import one.mixin.android.extension.numberFormat12 import one.mixin.android.tip.wc.WalletConnect import one.mixin.android.tip.wc.internal.Chain -import one.mixin.android.tip.wc.internal.Method import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WCEthereumSignMessage import one.mixin.android.tip.wc.internal.WCEthereumTransaction import one.mixin.android.ui.home.web3.components.ActionBottom -import one.mixin.android.ui.home.web3.components.ActionButton import one.mixin.android.ui.home.web3.components.MessagePreview import one.mixin.android.ui.home.web3.components.TransactionPreview import one.mixin.android.ui.home.web3.components.Warning @@ -102,7 +99,6 @@ fun SessionRequestPage( val commonWallet = stringResource(R.string.Common_Wallet) var walletName by remember { mutableStateOf(null) } var walletDisplayInfo by remember { mutableStateOf?>(null) } - var chainToken by remember { mutableStateOf(null) } if (version != WalletConnect.Version.TIP && (signData == null || sessionRequest == null)) { Loading() @@ -141,15 +137,6 @@ fun SessionRequestPage( } } - LaunchedEffect(Unit) { - try { - chainToken = viewModel.web3TokenItemById(Web3Signer.currentWalletId, assetId = chain.assetId) - } catch (e: Exception) { - Timber.e(e) - } - } - - val fee = tipGas?.displayValue( if (sessionRequestUI.data is WCEthereumTransaction) { sessionRequestUI.data.maxFeePerGas @@ -386,7 +373,7 @@ fun SessionRequestPage( } } } else if (step == WalletConnectBottomSheetDialogFragment.Step.Sign) { - if (signData?.sessionRequest?.request?.method == Method.ETHSignTransaction.name || signData?.sessionRequest?.request?.method == Method.ETHSendTransaction.name && tipGas == null) { + if (sessionRequestUI.data is WCEthereumTransaction && tipGas == null) { Column(modifier = Modifier.align(Alignment.BottomCenter)) { Box(modifier = Modifier.height(20.dp)) CircularProgressIndicator( @@ -398,32 +385,10 @@ fun SessionRequestPage( ) } } else { - if (fee != null && fee > BigDecimal.ZERO && (chainToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO) <= BigDecimal.ZERO) { - Row( - modifier = - Modifier - .background(MixinAppTheme.colors.background) - .padding(8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - ActionButton( - text = stringResource(id = R.string.insufficient_balance_symbol, chain.symbol), - onClick = { - viewModel.rejectRequest(version, topic) - onDismissRequest.invoke() - }, - backgroundColor = MixinAppTheme.colors.backgroundGray, - contentColor = MixinAppTheme.colors.textPrimary - ) - Box(modifier = Modifier.width(36.dp)) - } - } else { - ActionBottom(modifier = Modifier.align(Alignment.BottomCenter), stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), { - viewModel.rejectRequest(version, topic) - onDismissRequest.invoke() - }, showPin) - } + ActionBottom(modifier = Modifier.align(Alignment.BottomCenter), stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), { + viewModel.rejectRequest(version, topic) + onDismissRequest.invoke() + }, showPin) } } diff --git a/app/src/main/java/one/mixin/android/web3/DappTransactionPreflight.kt b/app/src/main/java/one/mixin/android/web3/DappTransactionPreflight.kt new file mode 100644 index 0000000000..4921ab2bc2 --- /dev/null +++ b/app/src/main/java/one/mixin/android/web3/DappTransactionPreflight.kt @@ -0,0 +1,63 @@ +package one.mixin.android.web3 + +import one.mixin.android.api.DataErrorException +import one.mixin.android.api.MixinResponse +import one.mixin.android.api.request.web3.EstimateFeeRequest +import one.mixin.android.api.request.web3.EstimateFeeResponse +import one.mixin.android.db.web3.vo.Web3TokenFeeItem +import one.mixin.android.db.web3.vo.Web3TokenItem +import one.mixin.android.tip.wc.internal.Chain +import one.mixin.android.tip.wc.internal.TipGas +import one.mixin.android.tip.wc.internal.buildTipGas +import one.mixin.android.web3.js.JsSignMessage +import one.mixin.android.web3.js.throwIfAnyMaliciousInstruction +import org.sol4kt.VersionedTransactionCompat +import org.web3j.utils.Convert +import org.web3j.utils.Numeric +import java.math.BigDecimal + +data class DappTransactionPreflight( + val balance: Web3TokenFeeItem, + val tipGas: TipGas? = null, +) { + val insufficientBalance: Boolean + get() = balance.amount + balance.fee > balance.token.balance.toBigDecimal() +} + +suspend fun preflightDappTransaction( + message: JsSignMessage, + chain: Chain, + account: String, + findToken: suspend (String) -> Web3TokenItem?, + estimateFee: suspend (EstimateFeeRequest) -> MixinResponse, + cachedTipGas: TipGas? = null, +): DappTransactionPreflight? { + val amount: BigDecimal + val fee: BigDecimal + val tipGas: TipGas? + when (message.type) { + JsSignMessage.TYPE_TRANSACTION -> { + val transaction = message.wcEthereumTransaction ?: throw DataErrorException() + amount = Convert.fromWei(Numeric.decodeQuantity(transaction.value ?: "0x0").toBigDecimal(), Convert.Unit.ETHER) + tipGas = cachedTipGas ?: estimateFee( + EstimateFeeRequest(chain.getWeb3ChainId(), null, transaction.data, transaction.from, transaction.to, transaction.value), + ).let { response -> + if (!response.isSuccess) throw Web3Exception(response.errorCode, response.errorDescription) + buildTipGas(chain.chainId, response.data ?: throw DataErrorException()) + } + fee = tipGas.displayValue(transaction.maxFeePerGas) ?: throw DataErrorException() + } + JsSignMessage.TYPE_RAW_TRANSACTION -> { + val transaction = VersionedTransactionCompat.from(message.data ?: throw DataErrorException()) + transaction.throwIfAnyMaliciousInstruction() + amount = BigDecimal.ZERO + fee = transaction.calcFee(account) + tipGas = null + if (fee == BigDecimal.ZERO) return null + } + else -> return null + } + require(amount >= BigDecimal.ZERO && fee >= BigDecimal.ZERO) + val token = findToken(chain.getWeb3ChainId()) ?: throw DataErrorException() + return DappTransactionPreflight(Web3TokenFeeItem(token, amount, fee), tipGas) +} diff --git a/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrorsTest.kt b/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrorsTest.kt new file mode 100644 index 0000000000..b59984e117 --- /dev/null +++ b/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectPairingErrorsTest.kt @@ -0,0 +1,51 @@ +package one.mixin.android.tip.wc.internal + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class WalletConnectPairingErrorsTest { + @Test + fun sdkErrorEndsActivePairingWait() = runBlocking { + val errors = WalletConnectPairingErrors() + val pending = async(start = CoroutineStart.UNDISPATCHED) { errors.await() } + val error = IllegalStateException("No pending proposal") + + errors.report(error) + + assertSame(error, pending.await()) + } + + @Test + fun missingProposalTimesOut() = runBlocking { + assertNull(WalletConnectPairingErrors().await(timeoutMillis = 10)) + } + + @Test + fun backgroundErrorsAreNotReplayedWhenPairingStarts() = runBlocking { + val errors = WalletConnectPairingErrors() + errors.report(IllegalStateException("Previous session error")) + + assertNull(errors.await(timeoutMillis = 10)) + } + + @Test + fun dismissingConnectionCancelsWaitWithoutAffectingRetry() = runBlocking { + val errors = WalletConnectPairingErrors() + val dismissed = async(start = CoroutineStart.UNDISPATCHED) { errors.await() } + dismissed.cancelAndJoin() + assertTrue(dismissed.isCancelled) + errors.report(IllegalStateException("Error while dismissed")) + + val retry = async(start = CoroutineStart.UNDISPATCHED) { errors.await() } + val error = IllegalStateException("Current pairing error") + errors.report(error) + + assertSame(error, retry.await()) + } +} diff --git a/app/src/test/java/one/mixin/android/web3/DappTransactionPreflightTest.kt b/app/src/test/java/one/mixin/android/web3/DappTransactionPreflightTest.kt new file mode 100644 index 0000000000..7ec4ed7a08 --- /dev/null +++ b/app/src/test/java/one/mixin/android/web3/DappTransactionPreflightTest.kt @@ -0,0 +1,188 @@ +package one.mixin.android.web3 + +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.DataErrorException +import one.mixin.android.api.MixinResponse +import one.mixin.android.api.ResponseError +import one.mixin.android.api.request.web3.EstimateFeeResponse +import one.mixin.android.db.web3.vo.Web3TokenItem +import one.mixin.android.tip.wc.internal.Chain +import one.mixin.android.tip.wc.internal.WCEthereumTransaction +import one.mixin.android.web3.js.JsSignMessage +import org.junit.Test +import org.sol4k.PublicKey +import org.sol4k.Transaction +import org.sol4k.instruction.TransferInstruction +import org.sol4kt.addPlaceholderSignature +import org.sol4kt.VersionedTransactionCompat +import java.math.BigDecimal +import java.util.Base64 +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DappTransactionPreflightTest { + @Test + fun checksValueAndFeeWithoutAnExplicitTransferToken() = runBlocking { + val result = preflight(ethereumMessage(value = "0xde0b6b3a7640000"), "1.00001") + + assertTrue(result.insufficientBalance) + assertEquals(0, result.balance.amount.compareTo(BigDecimal.ONE)) + assertEquals(0, result.balance.fee.compareTo(BigDecimal("0.000021"))) + } + + @Test + fun rejectsPositiveBalanceBelowFee() = runBlocking { + assertTrue(preflight(ethereumMessage(), "0.000001").insufficientBalance) + } + + @Test + fun rejectsZeroBalance() = runBlocking { + assertTrue(preflight(ethereumMessage(), "0").insufficientBalance) + } + + @Test + fun allowsExactValueAndFeeBalance() = runBlocking { + assertFalse(preflight(ethereumMessage(value = "0xde0b6b3a7640000"), "1.000021").insufficientBalance) + } + + @Test + fun includesHigherRequestedMaxFee() = runBlocking { + val result = preflight(ethereumMessage(maxFee = "0x77359400"), "0.00003") + + assertTrue(result.insufficientBalance) + assertEquals(0, result.balance.fee.compareTo(BigDecimal("0.000042"))) + } + + @Test + fun messageSigningDoesNotRequireBalanceOrEstimateFee() = runBlocking { + for (type in listOf(JsSignMessage.TYPE_MESSAGE, JsSignMessage.TYPE_PERSONAL_MESSAGE, JsSignMessage.TYPE_TYPED_MESSAGE, JsSignMessage.TYPE_SIGN_IN)) { + assertNull( + preflightDappTransaction( + JsSignMessage(1, type, data = "message"), Chain.Ethereum, "account", + findToken = { error("Message signing must not load a balance") }, + estimateFee = { error("Message signing must not estimate a fee") }, + ), + ) + } + } + + @Test + fun failedFeeEstimateCannotProceedToSigning() = runBlocking { + val exception = assertFailsWith { + preflightDappTransaction( + ethereumMessage(), Chain.Ethereum, "account", + findToken = { token("1") }, + estimateFee = { MixinResponse(ResponseError(400, 10001, "Estimation failed")) }, + ) + } + assertEquals(10001, exception.code) + } + + @Test + fun missingBalanceCannotProceedToSigning() = runBlocking { + assertFailsWith { + preflightDappTransaction( + ethereumMessage(), Chain.Ethereum, "account", + findToken = { null }, + estimateFee = { feeResponse() }, + ) + } + } + + @Test + fun rejectsSolanaBalanceBelowActualFee() = runBlocking { + val payer = PublicKey("5TDMKU3basuWC9sb9xAJgvn17KYFTLk9srPifmjZqJH9") + val recipient = PublicKey("9B5XszUGdMaxCZ7uSQhPzdks5ZQSmWxrmzCSvtJ6Ns6g") + val transaction = Transaction(payer.toBase58(), TransferInstruction(payer, recipient, 100L), payer) + transaction.addPlaceholderSignature() + val result = preflightDappTransaction( + JsSignMessage(1, JsSignMessage.TYPE_RAW_TRANSACTION, data = Base64.getEncoder().encodeToString(transaction.serialize())), + Chain.Solana, + payer.toBase58(), + findToken = { token("0.000004", Chain.Solana) }, + estimateFee = { error("Solana fees are calculated from the transaction") }, + ) + + assertNotNull(result) + assertTrue(result.insufficientBalance) + assertEquals(0, result.balance.fee.compareTo(BigDecimal("0.000005"))) + } + + @Test + fun solanaCosignerDoesNotNeedFeePayerBalance() = runBlocking { + val payer = PublicKey("5TDMKU3basuWC9sb9xAJgvn17KYFTLk9srPifmjZqJH9") + val cosigner = PublicKey("9B5XszUGdMaxCZ7uSQhPzdks5ZQSmWxrmzCSvtJ6Ns6g") + val transaction = Transaction(payer.toBase58(), TransferInstruction(payer, cosigner, 100L), payer) + transaction.addPlaceholderSignature() + val parsed = VersionedTransactionCompat.from(Base64.getEncoder().encodeToString(transaction.serialize())) + val sponsored = VersionedTransactionCompat( + parsed.message.copy(header = parsed.message.header.copy(numRequireSignatures = 2)), + mutableListOf(parsed.signatures.first(), parsed.signatures.first()), + ) + + assertNull( + preflightDappTransaction( + JsSignMessage(1, JsSignMessage.TYPE_RAW_TRANSACTION, data = Base64.getEncoder().encodeToString(sponsored.serialize())), + Chain.Solana, + cosigner.toBase58(), + findToken = { error("A cosigner does not pay the transaction fee") }, + estimateFee = { error("Solana fees are calculated from the transaction") }, + ), + ) + } + + @Test + fun cachedFeeStillChecksTheLatestBalance() = runBlocking { + val original = preflight(ethereumMessage(), "1") + val refreshed = preflightDappTransaction( + ethereumMessage(), Chain.Ethereum, "account", + findToken = { token("0.000001") }, + estimateFee = { error("The existing estimate should be reused") }, + cachedTipGas = original.tipGas, + ) + + assertNotNull(refreshed) + assertTrue(refreshed.insufficientBalance) + } + + private suspend fun preflight(message: JsSignMessage, balance: String) = requireNotNull( + preflightDappTransaction( + message, Chain.Ethereum, "account", + findToken = { token(balance) }, + estimateFee = { feeResponse() }, + ), + ) + + private fun ethereumMessage(value: String? = null, maxFee: String? = null) = JsSignMessage( + 1, + JsSignMessage.TYPE_TRANSACTION, + wcEthereumTransaction = WCEthereumTransaction("account", "recipient", null, null, maxFee, null, null, null, value, null), + ) + + private fun feeResponse() = MixinResponse().apply { + data = EstimateFeeResponse(Chain.Ethereum.assetId, "21000", "1000000000", "1000000000", null, null, null, null) + } + + private fun token(balance: String, chain: Chain = Chain.Ethereum) = Web3TokenItem( + walletId = "wallet", + assetId = chain.assetId, + chainId = chain.assetId, + name = chain.name, + assetKey = "", + symbol = chain.symbol, + iconUrl = "", + precision = 18, + balance = balance, + priceUsd = "0", + changeUsd = "0", + chainIcon = null, + chainName = null, + chainSymbol = null, + hidden = false, + level = 0, + ) +}