diff --git a/app/src/main/java/one/mixin/android/api/request/web3/GaslessFeeRequest.kt b/app/src/main/java/one/mixin/android/api/request/web3/GaslessFeeRequest.kt new file mode 100644 index 0000000000..0ac4b41a67 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/request/web3/GaslessFeeRequest.kt @@ -0,0 +1,12 @@ +package one.mixin.android.api.request.web3 + +import com.google.gson.annotations.SerializedName + +data class GaslessFeeRequest( + val from: String, + val to: String, + @SerializedName("asset_id") + val assetId: String, + @SerializedName("chain_id") + val chainId: String, +) \ No newline at end of file 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 new file mode 100644 index 0000000000..c9f741e7cc --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/request/web3/GaslessTxRequest.kt @@ -0,0 +1,15 @@ +package one.mixin.android.api.request.web3 + +import com.google.gson.annotations.SerializedName + +data class GaslessTxRequest( + val from: String, + val to: String, + @SerializedName("asset_id") + val assetId: String, + val amount: String, + @SerializedName("fee_asset_id") + val feeAssetId: String, + @SerializedName("chain_id") + val chainId: String, +) \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/request/web3/SubmitGaslessTxRequest.kt b/app/src/main/java/one/mixin/android/api/request/web3/SubmitGaslessTxRequest.kt new file mode 100644 index 0000000000..cb58814899 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/request/web3/SubmitGaslessTxRequest.kt @@ -0,0 +1,14 @@ +package one.mixin.android.api.request.web3 + +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName + +data class SubmitGaslessTxRequest( + @SerializedName("chain_id") + val chainId: String, + val payload: JsonElement, + @SerializedName("user_op_signature") + val userOpSignature: String, + @SerializedName("eip7702_auth_signature") + val eip7702AuthSignature: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/web3/GaslessFeeResponse.kt b/app/src/main/java/one/mixin/android/api/response/web3/GaslessFeeResponse.kt new file mode 100644 index 0000000000..2d4cf18c30 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/web3/GaslessFeeResponse.kt @@ -0,0 +1,13 @@ +package one.mixin.android.api.response.web3 + +import com.google.gson.annotations.SerializedName + +data class GaslessFeeResponse( + val fees: List, +) + +data class GaslessFeeEstimate( + @SerializedName("asset_id") + val assetId: String, + val amount: String, +) \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/web3/GaslessSponsorTransactionResponse.kt b/app/src/main/java/one/mixin/android/api/response/web3/GaslessSponsorTransactionResponse.kt new file mode 100644 index 0000000000..d475068ce4 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/web3/GaslessSponsorTransactionResponse.kt @@ -0,0 +1,34 @@ +package one.mixin.android.api.response.web3 + +import com.google.gson.annotations.SerializedName +import one.mixin.android.db.web3.vo.TransactionStatus + +data class GaslessSponsorTransactionResponse( + @SerializedName("sponsor_tx_id") + val sponsorTxId: String, + @SerializedName("chain_id") + val chainId: String, + val account: String, + @SerializedName("web3_chain_id") + val web3ChainId: Int, + val state: String, + @SerializedName("broadcast_tx_hash") + val broadcastTxHash: String? = null, + val reason: String? = null, + @SerializedName("created_at") + val createdAt: String, + @SerializedName("updated_at") + val updatedAt: String, +) + +fun GaslessSponsorTransactionResponse.hasBroadcastTxHash(): Boolean = + !broadcastTxHash.isNullOrBlank() + +fun GaslessSponsorTransactionResponse.toPendingStatusOrNull(): String? { + if (hasBroadcastTxHash()) return TransactionStatus.PENDING.value + if (!reason.isNullOrBlank()) return TransactionStatus.FAILED.value + return when (state.lowercase()) { + "failed", "rejected", "cancelled", "canceled", "expired" -> TransactionStatus.FAILED.value + else -> null + } +} diff --git a/app/src/main/java/one/mixin/android/api/response/web3/GaslessTxResponse.kt b/app/src/main/java/one/mixin/android/api/response/web3/GaslessTxResponse.kt new file mode 100644 index 0000000000..692328da2a --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/web3/GaslessTxResponse.kt @@ -0,0 +1,49 @@ +package one.mixin.android.api.response.web3 + +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName + +data class GaslessTxResponse( + @SerializedName("chain_id") + val chainId: String, + val payload: JsonElement, +) + +data class EthGaslessTxPayload( + val userOperation: UserOperationJson, + val signing: EthGaslessSignRequests, +) + +data class UserOperationJson( + val sender: String, + val nonce: String, + val initCode: String, + val callData: String, + val callGasLimit: String, + val verificationGasLimit: String, + val preVerificationGas: String, + val maxFeePerGas: String, + val maxPriorityFeePerGas: String, + val paymasterAndData: String, + val signature: String, +) + +data class EthGaslessSignRequests( + val userOperation: UserOpSignRequest, + val eip7702Auth: EIP7702SignRequest? = null, +) + +data class UserOpSignRequest( + val signType: String, + val message: String, +) + +data class EIP7702SignRequest( + val required: Boolean, + val signType: String, + val message: String, + @SerializedName("chainId") + val chainId: String, + val address: String, + val nonce: String, +) \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/web3/SubmitGaslessTxResponse.kt b/app/src/main/java/one/mixin/android/api/response/web3/SubmitGaslessTxResponse.kt new file mode 100644 index 0000000000..20f192a2d0 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/web3/SubmitGaslessTxResponse.kt @@ -0,0 +1,8 @@ +package one.mixin.android.api.response.web3 + +import com.google.gson.annotations.SerializedName + +data class SubmitGaslessTxResponse( + @SerializedName("sponsor_tx_id") + val sponsorTxId: String, +) diff --git a/app/src/main/java/one/mixin/android/api/service/RouteService.kt b/app/src/main/java/one/mixin/android/api/service/RouteService.kt index e305afd3e7..f530e57439 100644 --- a/app/src/main/java/one/mixin/android/api/service/RouteService.kt +++ b/app/src/main/java/one/mixin/android/api/service/RouteService.kt @@ -11,8 +11,11 @@ import one.mixin.android.api.request.RouteTickerRequest import one.mixin.android.api.request.RouteTokenRequest import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.request.web3.EstimateFeeResponse +import one.mixin.android.api.request.web3.GaslessFeeRequest +import one.mixin.android.api.request.web3.GaslessTxRequest import one.mixin.android.api.request.web3.RpcRequest import one.mixin.android.api.request.web3.StakeRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.request.web3.SwapRequest import one.mixin.android.api.request.web3.WalletRequest import one.mixin.android.api.request.web3.Web3RawTransactionRequest @@ -24,12 +27,16 @@ import one.mixin.android.api.response.RouteTickerResponse import one.mixin.android.api.response.UserAddressView import one.mixin.android.api.response.web3.ParsedTx import one.mixin.android.api.response.web3.QuoteResult +import one.mixin.android.api.response.web3.GaslessFeeResponse +import one.mixin.android.api.response.web3.GaslessSponsorTransactionResponse import one.mixin.android.api.response.web3.StakeAccount import one.mixin.android.api.response.web3.StakeAccountActivation import one.mixin.android.api.response.web3.StakeResponse +import one.mixin.android.api.response.web3.SubmitGaslessTxResponse import one.mixin.android.api.response.web3.SwapResponse import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.api.response.web3.Tx +import one.mixin.android.api.response.web3.GaslessTxResponse import one.mixin.android.api.response.web3.Validator import one.mixin.android.api.response.web3.WalletOutput import one.mixin.android.db.web3.vo.Web3Address @@ -271,6 +278,26 @@ interface RouteService { @Body request: EstimateFeeRequest, ): MixinResponse + @POST("web3/gasless/fees") + suspend fun gaslessFee( + @Body request: GaslessFeeRequest, + ): MixinResponse + + @POST("web3/gasless/prepare") + suspend fun gaslessTx( + @Body request: GaslessTxRequest, + ): MixinResponse + + @POST("web3/gasless/submit") + suspend fun submitGaslessTx( + @Body request: SubmitGaslessTxRequest, + ): MixinResponse + + @GET("web3/gasless/transactions/{id}") + suspend fun gaslessTransaction( + @Path("id") id: String, + ): MixinResponse + @POST("web3/rpc") suspend fun rpc( @Query("chain_id") chainId: String, diff --git a/app/src/main/java/one/mixin/android/db/web3/Web3RawTransactionDao.kt b/app/src/main/java/one/mixin/android/db/web3/Web3RawTransactionDao.kt index 077e9caed4..b0f8f5b2c5 100644 --- a/app/src/main/java/one/mixin/android/db/web3/Web3RawTransactionDao.kt +++ b/app/src/main/java/one/mixin/android/db/web3/Web3RawTransactionDao.kt @@ -20,6 +20,9 @@ interface Web3RawTransactionDao : BaseDao { @Query("SELECT * FROM raw_transactions WHERE hash = :hash AND chain_id = :chainId AND account IN (SELECT DISTINCT destination FROM addresses WHERE wallet_id = :walletId)") suspend fun getRawTransactionByHashAndChain(walletId:String, hash: String, chainId: String): Web3RawTransaction? - @Query("SELECT nonce FROM raw_transactions WHERE chain_id = :chainId AND state = 'pending' AND account IN (SELECT DISTINCT destination FROM addresses WHERE wallet_id = :walletId) ORDER BY nonce DESC LIMIT 1") + @Query("DELETE FROM raw_transactions WHERE hash = :hash AND chain_id = :chainId") + suspend fun deleteByHashAndChain(hash: String, chainId: String) + + @Query("SELECT nonce FROM raw_transactions WHERE chain_id = :chainId AND state = 'pending' AND raw NOT LIKE 'gasless:%' AND account IN (SELECT DISTINCT destination FROM addresses WHERE wallet_id = :walletId) ORDER BY nonce DESC LIMIT 1") suspend fun getNonce(walletId:String, chainId: String): String? } diff --git a/app/src/main/java/one/mixin/android/db/web3/vo/Web3RawTransaction.kt b/app/src/main/java/one/mixin/android/db/web3/vo/Web3RawTransaction.kt index 201c24bc25..89af7722a1 100644 --- a/app/src/main/java/one/mixin/android/db/web3/vo/Web3RawTransaction.kt +++ b/app/src/main/java/one/mixin/android/db/web3/vo/Web3RawTransaction.kt @@ -44,4 +44,19 @@ data class Web3RawTransaction( @Ignore @SerializedName("simulate_tx") var simulateTx: ParsedTx? = null -} \ No newline at end of file +} + +private const val GASLESS_PENDING_SPONSOR_PREFIX = "gasless:sponsor:" +private const val GASLESS_PENDING_BROADCAST_PREFIX = "gasless:broadcast:" + +fun Web3RawTransaction.isGaslessPending(): Boolean = + raw.startsWith(GASLESS_PENDING_SPONSOR_PREFIX) || raw.startsWith(GASLESS_PENDING_BROADCAST_PREFIX) + +fun Web3RawTransaction.isGaslessSponsorPending(): Boolean = + raw.startsWith(GASLESS_PENDING_SPONSOR_PREFIX) + +fun buildGaslessSponsorPendingRawMarker(sponsorTxId: String): String = + "$GASLESS_PENDING_SPONSOR_PREFIX$sponsorTxId" + +fun buildGaslessBroadcastPendingRawMarker(broadcastTxHash: String): String = + "$GASLESS_PENDING_BROADCAST_PREFIX$broadcastTxHash" diff --git a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt index 0c17e74581..2b49fd0080 100644 --- a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt @@ -87,6 +87,8 @@ import one.mixin.android.db.web3.vo.TransactionType import one.mixin.android.db.web3.vo.Web3RawTransaction import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.Web3Transaction +import one.mixin.android.db.web3.vo.buildGaslessBroadcastPendingRawMarker +import one.mixin.android.db.web3.vo.buildGaslessSponsorPendingRawMarker import one.mixin.android.extension.hexString import one.mixin.android.extension.hexStringToByteArray import one.mixin.android.extension.isUUID @@ -1089,121 +1091,162 @@ class TokenRepository val r = routeService.postWeb3Tx(rawTxRequest) if (r.isSuccess) { val raw = r.data!! - if (rate == null) { - web3RawTransactionDao.insertSuspend(raw) - } else { - web3RawTransactionDao.insertSuspend( - raw.copy( - nonce = rate.toPlainString() - ) - ) - } + val existingPendingTransaction = web3TransactionDao.getLatestTransaction(raw.hash, raw.chainId) + val gaslessPendingTransaction = existingPendingTransaction?.takeIf { it.fee.isNotBlank() } + web3RawTransactionDao.insertSuspend( + buildRawTransactionForInsert(raw, gaslessPendingTransaction, rate) + ) web3TransactionDao.deletePending(raw.hash, raw.chainId) + web3TransactionDao.insert( + buildPendingTransactionForInsert(raw, assetId, existingPendingTransaction, gaslessPendingTransaction, rate) + ) + } + return r + } - val senders = mutableListOf() - val receivers = mutableListOf() - val approvals = mutableListOf() - - raw.simulateTx?.balanceChanges?.forEach { bc -> - val amt = bc.amount.toBigDecimalOrNull() - if (amt != null) { - receivers.add( - AssetChange( - assetId = bc.assetId, - amount = amt.abs().toPlainString(), - from = bc.from, - to = bc.to - ) - ) - senders.add( - AssetChange( - assetId = bc.assetId, - amount = amt.toPlainString(), - from = bc.from, - to = bc.to, - ) - ) - } - } + private fun buildRawTransactionForInsert( + raw: Web3RawTransaction, + gaslessPendingTransaction: Web3Transaction?, + rate: BigDecimal?, + ): Web3RawTransaction { + val resolvedAddress = gaslessPendingTransaction?.address ?: raw.account + return when { + rate != null -> raw.copy( + account = resolvedAddress, + nonce = rate.toPlainString(), + ) + resolvedAddress != raw.account -> raw.copy(account = resolvedAddress) + else -> raw + } + } - raw.simulateTx?.approves?.forEach { approve -> - approvals.add( + private fun buildPendingTransactionForInsert( + raw: Web3RawTransaction, + assetId: String?, + existingPendingTransaction: Web3Transaction?, + gaslessPendingTransaction: Web3Transaction?, + rate: BigDecimal?, + ): Web3Transaction { + val resolvedAddress = gaslessPendingTransaction?.address ?: raw.account + val senders = mutableListOf() + val receivers = mutableListOf() + val approvals = mutableListOf() + + raw.simulateTx?.balanceChanges?.forEach { bc -> + val amt = bc.amount.toBigDecimalOrNull() + if (amt != null) { + receivers.add( + AssetChange( + assetId = bc.assetId, + amount = amt.abs().toPlainString(), + from = bc.from, + to = bc.to + ) + ) + senders.add( AssetChange( - assetId = assetId ?: "", - amount = approve.amount, - from = raw.account, - to = approve.spender + assetId = bc.assetId, + amount = amt.toPlainString(), + from = bc.from, + to = bc.to, ) ) } + } - var sendAssetId: String? = null - var receiveAssetId: String? = null + raw.simulateTx?.approves?.forEach { approve -> + approvals.add( + AssetChange( + assetId = assetId ?: "", + amount = approve.amount, + from = resolvedAddress, + to = approve.spender + ) + ) + } - val txType = when { - assetId == Constants.ChainId.BITCOIN_CHAIN_ID -> TransactionType.TRANSFER_OUT.value - raw.simulateTx?.approves?.isNotEmpty() == true -> TransactionType.APPROVAL.value - (raw.simulateTx?.balanceChanges?.size ?: 0) > 1 -> TransactionType.SWAP.value - raw.simulateTx?.balanceChanges?.size == 1 -> TransactionType.TRANSFER_OUT.value - else -> TransactionType.UNKNOWN.value - } + var sendAssetId: String? = null + var receiveAssetId: String? = null - when (txType) { - TransactionType.SWAP.value -> { - raw.simulateTx?.balanceChanges?.forEach { bc -> - val amt = bc.amount.toBigDecimalOrNull() - if (amt != null) { - if (amt < BigDecimal.ZERO) { - sendAssetId = bc.assetId - } else if (amt > BigDecimal.ZERO) { - receiveAssetId = bc.assetId - } + val txType = when { + assetId == Constants.ChainId.BITCOIN_CHAIN_ID -> TransactionType.TRANSFER_OUT.value + raw.simulateTx?.approves?.isNotEmpty() == true -> TransactionType.APPROVAL.value + (raw.simulateTx?.balanceChanges?.size ?: 0) > 1 -> TransactionType.SWAP.value + raw.simulateTx?.balanceChanges?.size == 1 -> TransactionType.TRANSFER_OUT.value + else -> TransactionType.UNKNOWN.value + } + + when (txType) { + TransactionType.SWAP.value -> { + raw.simulateTx?.balanceChanges?.forEach { bc -> + val amt = bc.amount.toBigDecimalOrNull() + if (amt != null) { + if (amt < BigDecimal.ZERO) { + sendAssetId = bc.assetId + } else if (amt > BigDecimal.ZERO) { + receiveAssetId = bc.assetId } } } - TransactionType.TRANSFER_OUT.value -> { - raw.simulateTx?.balanceChanges?.firstOrNull { - it.amount.toBigDecimalOrNull()?.let { amt -> amt < BigDecimal.ZERO } == true - }?.let { - sendAssetId = it.assetId - receiveAssetId = it.assetId - } - } - else -> { - sendAssetId = assetId - receiveAssetId = assetId - } } - - if (assetId == Constants.ChainId.BITCOIN_CHAIN_ID) { - sendAssetId = Constants.ChainId.BITCOIN_CHAIN_ID - receiveAssetId = Constants.ChainId.BITCOIN_CHAIN_ID + TransactionType.TRANSFER_OUT.value -> { + raw.simulateTx?.balanceChanges?.firstOrNull { + it.amount.toBigDecimalOrNull()?.let { amt -> amt < BigDecimal.ZERO } == true + }?.let { + sendAssetId = it.assetId + receiveAssetId = it.assetId + } } - if (raw.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { - Timber.e("bitcoin tx,hash=%s, rate=%s", raw.hash, rate ?: "null") + else -> { + sendAssetId = assetId + receiveAssetId = assetId } - web3TransactionDao.insert( - Web3Transaction( - transactionHash = raw.hash, - chainId = raw.chainId, - address = raw.account, - transactionType = txType, - status = TransactionStatus.PENDING.value, - blockNumber = 0, - fee = "", - senders = senders, - receivers = receivers, - approvals = approvals.ifEmpty { null }, - sendAssetId = sendAssetId, - receiveAssetId = receiveAssetId, - transactionAt = raw.updatedAt, - createdAt = raw.createdAt, - updatedAt = raw.updatedAt, - level = Constants.AssetLevel.GOOD, - ) - ) } - return r + + if (assetId == Constants.ChainId.BITCOIN_CHAIN_ID) { + sendAssetId = Constants.ChainId.BITCOIN_CHAIN_ID + receiveAssetId = Constants.ChainId.BITCOIN_CHAIN_ID + } + if (raw.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { + Timber.e("bitcoin tx,hash=%s, rate=%s", raw.hash, rate ?: "null") + } + + val shouldFallbackToGaslessPending = sendAssetId == null && receiveAssetId == null && gaslessPendingTransaction != null + + return Web3Transaction( + transactionHash = raw.hash, + chainId = raw.chainId, + address = resolvedAddress, + transactionType = if (shouldFallbackToGaslessPending) { + gaslessPendingTransaction.transactionType + } else { + txType + }, + status = TransactionStatus.PENDING.value, + blockNumber = 0, + fee = existingPendingTransaction?.fee.orEmpty(), + senders = if (shouldFallbackToGaslessPending && gaslessPendingTransaction.senders != null) { + gaslessPendingTransaction.senders + } else { + senders + }, + receivers = if (shouldFallbackToGaslessPending && gaslessPendingTransaction.receivers != null) { + gaslessPendingTransaction.receivers + } else { + receivers + }, + approvals = if (approvals.isEmpty() && gaslessPendingTransaction?.approvals != null) { + gaslessPendingTransaction.approvals + } else { + approvals.ifEmpty { null } + }, + sendAssetId = sendAssetId ?: gaslessPendingTransaction?.sendAssetId, + receiveAssetId = receiveAssetId ?: gaslessPendingTransaction?.receiveAssetId, + transactionAt = raw.updatedAt, + createdAt = raw.createdAt, + updatedAt = raw.updatedAt, + level = Constants.AssetLevel.GOOD, + ) } @@ -1483,6 +1526,179 @@ class TokenRepository suspend fun getPendingTransactions(walletId: String) = web3TransactionDao.getPendingTransactions(walletId) + suspend fun insertGaslessPendingTransaction( + sponsorTxId: String, + chainId: String, + account: String, + assetId: String, + amount: String, + fee: String, + to: String, + nonce: String, + createdAt: String, + updatedAt: String, + ) { + insertPendingTransaction( + hash = sponsorTxId, + chainId = chainId, + account = account, + assetId = assetId, + amount = amount, + fee = fee, + to = to, + raw = buildGaslessSponsorPendingRawMarker(sponsorTxId), + nonce = nonce, + createdAt = createdAt, + updatedAt = updatedAt, + ) + } + + suspend fun insertSignedPendingTransaction( + hash: String, + chainId: String, + account: String, + assetId: String, + amount: String, + fee: String, + to: String, + raw: String, + createdAt: String, + updatedAt: String, + ) { + insertPendingTransaction( + hash = hash, + chainId = chainId, + account = account, + assetId = assetId, + amount = amount, + fee = fee, + to = to, + raw = raw, + nonce = "", + createdAt = createdAt, + updatedAt = updatedAt, + ) + } + + private suspend fun insertPendingTransaction( + hash: String, + chainId: String, + account: String, + assetId: String, + amount: String, + fee: String, + to: String, + raw: String, + nonce: String, + createdAt: String, + updatedAt: String, + ) { + val normalizedAmount = amount.removePrefix("-") + val normalizedFee = fee.toBigDecimalOrNull()?.stripTrailingZeros()?.toPlainString() ?: fee + appDatabase.withTransaction { + web3RawTransactionDao.insertSuspend( + Web3RawTransaction( + hash = hash, + chainId = chainId, + account = account, + nonce = nonce, + raw = raw, + state = TransactionStatus.PENDING.value, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + web3TransactionDao.deletePending(hash, chainId) + web3TransactionDao.insert( + Web3Transaction( + transactionHash = hash, + chainId = chainId, + address = account, + transactionType = TransactionType.TRANSFER_OUT.value, + status = TransactionStatus.PENDING.value, + blockNumber = 0, + fee = normalizedFee, + senders = listOf( + AssetChange( + assetId = assetId, + amount = "-$normalizedAmount", + from = account, + to = to, + ), + ), + receivers = listOf( + AssetChange( + assetId = assetId, + amount = normalizedAmount, + from = account, + to = to, + ), + ), + approvals = null, + sendAssetId = assetId, + receiveAssetId = assetId, + transactionAt = updatedAt, + createdAt = createdAt, + updatedAt = updatedAt, + level = Constants.AssetLevel.GOOD, + ), + ) + } + } + + suspend fun replaceGaslessPendingTransactionHash( + walletId: String, + sponsorTxId: String, + broadcastTxHash: String, + chainId: String, + updatedAt: String, + ) { + appDatabase.withTransaction { + val pendingRaw = web3RawTransactionDao.getRawTransactionByHashAndChain(walletId, sponsorTxId, chainId) + ?: return@withTransaction + val pendingTransaction = web3TransactionDao.getLatestTransaction(sponsorTxId, chainId) + + web3RawTransactionDao.insertSuspend( + pendingRaw.copy( + hash = broadcastTxHash, + raw = buildGaslessBroadcastPendingRawMarker(broadcastTxHash), + updatedAt = updatedAt, + ), + ) + if (pendingTransaction != null) { + web3TransactionDao.insert( + pendingTransaction.copy( + transactionHash = broadcastTxHash, + transactionAt = updatedAt, + updatedAt = updatedAt, + ), + ) + } + web3RawTransactionDao.deleteByHashAndChain(sponsorTxId, chainId) + web3TransactionDao.deletePending(sponsorTxId, chainId) + } + } + + suspend fun updateGaslessPendingTransactionStatus( + walletId: String, + hash: String, + chainId: String, + status: String, + updatedAt: String, + ) { + appDatabase.withTransaction { + val pendingRaw = web3RawTransactionDao.getRawTransactionByHashAndChain(walletId, hash, chainId) + ?: return@withTransaction + web3RawTransactionDao.insertSuspend( + pendingRaw.copy( + state = status, + updatedAt = updatedAt, + ), + ) + web3TransactionDao.updateTransaction(hash, status, chainId) + } + } + suspend fun insertRawTransactionAndUpdateTransactionStatus( raw: Web3RawTransaction, hash: String, diff --git a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt index 04d1bb4b11..69119336e9 100644 --- a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt +++ b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt @@ -10,8 +10,14 @@ import kotlinx.coroutines.flow.Flow import one.mixin.android.Constants import one.mixin.android.MixinApplication import one.mixin.android.api.request.AddressSearchRequest +import one.mixin.android.api.MixinResponse import one.mixin.android.api.request.web3.EstimateFeeRequest +import one.mixin.android.api.request.web3.GaslessFeeRequest +import one.mixin.android.api.request.web3.GaslessTxRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.request.web3.WalletRequest +import one.mixin.android.api.response.web3.GaslessSponsorTransactionResponse +import one.mixin.android.api.response.web3.SubmitGaslessTxResponse import one.mixin.android.api.service.RouteService import one.mixin.android.crypto.CryptoWalletHelper import one.mixin.android.db.property.Web3PropertyHelper @@ -69,6 +75,16 @@ constructor( ) { suspend fun estimateFee(request: EstimateFeeRequest) = routeService.estimateFee(request) + suspend fun gaslessFee(request: GaslessFeeRequest) = routeService.gaslessFee(request) + + suspend fun gaslessTx(request: GaslessTxRequest) = routeService.gaslessTx(request) + + suspend fun submitGaslessTx(request: SubmitGaslessTxRequest): MixinResponse = + routeService.submitGaslessTx(request) + + suspend fun gaslessTransaction(id: String): MixinResponse = + routeService.gaslessTransaction(id) + suspend fun refreshBitcoinTokenAmount(walletId: String, address: String) { if (walletId.isBlank() || address.isBlank()) return val wallet = web3WalletDao.getWalletById(walletId) ?:return diff --git a/app/src/main/java/one/mixin/android/ui/common/PendingTransactionRefreshHelper.kt b/app/src/main/java/one/mixin/android/ui/common/PendingTransactionRefreshHelper.kt index c907d82144..494bad896e 100644 --- a/app/src/main/java/one/mixin/android/ui/common/PendingTransactionRefreshHelper.kt +++ b/app/src/main/java/one/mixin/android/ui/common/PendingTransactionRefreshHelper.kt @@ -7,12 +7,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.db.web3.vo.TransactionStatus +import one.mixin.android.db.web3.vo.isGaslessSponsorPending import one.mixin.android.db.web3.vo.isTerminalTransactionStatus import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshWeb3BitCoinJob import one.mixin.android.job.RefreshWeb3TransactionsJob import one.mixin.android.ui.home.web3.Web3ViewModel import one.mixin.android.web3.js.Web3Signer +import one.mixin.android.api.response.web3.toPendingStatusOrNull import timber.log.Timber object PendingTransactionRefreshHelper { @@ -51,6 +53,36 @@ object PendingTransactionRefreshHelper { val pendingRawTransaction = web3ViewModel.getPendingRawTransactions(walletId) if (pendingRawTransaction.isEmpty().not()) { pendingRawTransaction.forEach { transition -> + if (transition.isGaslessSponsorPending()) { + val sponsorTransactionResponse = web3ViewModel.gaslessTransaction(transition.hash) + val sponsorTransaction = sponsorTransactionResponse.data + if (!sponsorTransactionResponse.isSuccess || sponsorTransaction == null) { + return@forEach + } + val broadcastTxHash = sponsorTransaction.broadcastTxHash?.takeIf { it.isNotBlank() } + if (broadcastTxHash != null) { + web3ViewModel.replaceGaslessPendingTransactionHash( + walletId = walletId, + sponsorTxId = transition.hash, + broadcastTxHash = broadcastTxHash, + chainId = transition.chainId, + updatedAt = sponsorTransaction.updatedAt, + ) + return@forEach + } + val gaslessStatus = sponsorTransaction.toPendingStatusOrNull() + if (gaslessStatus != null && gaslessStatus != TransactionStatus.PENDING.value) { + web3ViewModel.updateGaslessPendingTransactionStatus( + walletId = walletId, + hash = transition.hash, + chainId = transition.chainId, + status = gaslessStatus, + updatedAt = sponsorTransaction.updatedAt, + ) + onTransactionStatusUpdated?.invoke(transition.hash, gaslessStatus) + } + return@forEach + } val r = web3ViewModel.transaction(transition.hash, transition.chainId) if (r.isSuccess && r.data?.state.isTerminalTransactionStatus()) { val rawTransaction = r.data ?: return@forEach @@ -86,7 +118,6 @@ object PendingTransactionRefreshHelper { } delay(5_000) } else { - jobManager.addJobInBackground(RefreshWeb3TransactionsJob(walletId)) delay(15_000) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index 2b69af8761..1bc28b198c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt @@ -64,9 +64,15 @@ import one.mixin.android.Constants.INTERVAL_7_DAYS import one.mixin.android.MixinApplication import one.mixin.android.R import one.mixin.android.RxBus +import one.mixin.android.api.MixinResponse import one.mixin.android.api.request.SessionRequest +import one.mixin.android.api.request.web3.GaslessFeeRequest +import one.mixin.android.api.request.web3.GaslessTxRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest +import one.mixin.android.api.response.web3.EthGaslessTxPayload import one.mixin.android.api.service.ConversationService import one.mixin.android.api.service.UserService +import one.mixin.android.crypto.CryptoWalletHelper import one.mixin.android.crypto.PrivacyPreference.getIsLoaded import one.mixin.android.crypto.PrivacyPreference.getIsSyncSession import one.mixin.android.databinding.ActivityMainBinding @@ -84,6 +90,7 @@ import one.mixin.android.extension.checkStorageNotLow import one.mixin.android.extension.colorFromAttribute import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getStringDeviceId +import one.mixin.android.extension.hexStringToByteArray import one.mixin.android.extension.inTransaction import one.mixin.android.extension.indeterminateProgressDialog import one.mixin.android.extension.isExternalTransferUrl @@ -186,6 +193,7 @@ import one.mixin.android.vo.Participant import one.mixin.android.vo.ParticipantRole import one.mixin.android.vo.WalletCategory import one.mixin.android.vo.isGroupConversation +import one.mixin.android.web3.js.JsSignMessage import one.mixin.android.web3.js.Web3Signer import one.mixin.android.websocket.ReconnectWorker import one.mixin.android.worker.SessionWorker @@ -357,6 +365,7 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba handlerCode(intent) updateSessionWhenOpen() + checkAsync() RxBus.listen(TipEvent::class.java) @@ -1001,6 +1010,25 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } } + private fun requireMixinData( + response: MixinResponse, + action: String, + ): T { + if (!response.isSuccess) { + throw IllegalStateException("$action failed: ${response.errorDescription}") + } + return requireNotNull(response.data) { "$action returned empty data" } + } + + private fun requireMixinSuccess( + response: MixinResponse<*>, + action: String, + ) { + if (!response.isSuccess) { + throw IllegalStateException("$action failed: ${response.errorDescription}") + } + } + private fun showDialog() { alertDialog?.dismiss() alertDialog = @@ -1318,6 +1346,28 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba Timber.e("initFragmentsFromSavedState: nav_chat") } + private data class Eip7702GaslessDemoArgs( + val from: String?, + val to: String, + val assetId: String, + val amount: String, + val chainId: String, + val feeAssetId: String?, + ) + + private data class Eip7702GaslessDemoResult( + val from: String, + val to: String, + val assetId: String, + val amount: String, + val chainId: String, + val feeAssetId: String, + val userOpSignType: String, + val userOpSignature: String, + val eip7702Required: Boolean, + val eip7702AuthSignature: String?, + ) + companion object { const val URL = "url" const val SCAN = "scan" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt index 492f02497f..248f4f4930 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt @@ -84,6 +84,8 @@ fun BrowserPage( chain: Chain, amount: String?, token: Web3TokenItem?, + feeAmount: String?, + feeToken: Web3TokenItem?, toAddress: String?, toUser: User?, type: Int, @@ -304,6 +306,9 @@ fun BrowserPage( onPreviewMessage.invoke(it) } Box(modifier = Modifier.height(10.dp)) + } else if (type == JsSignMessage.TYPE_GASLESS_TRANSFER && token != null && amount != null) { + TokenTransactionPreview(amount = amount, token = token) + Box(modifier = Modifier.height(10.dp)) } else if (chain == Chain.Solana) { ParsedTxPreview(parsedTx = parsedTx, asset = asset, solanaTxSource = solanaTxSource) Box(modifier = Modifier.height(10.dp)) @@ -325,18 +330,20 @@ fun BrowserPage( ) Box(modifier = Modifier.height(10.dp)) } - val fee = tipGas?.displayValue(transaction?.maxFeePerGas) ?: solanaFee?.stripTrailingZeros()?: btcFee?.stripTrailingZeros() ?: BigDecimal.ZERO + val customFeeValue = feeAmount?.toBigDecimalOrNull() + val feePrice = feeToken?.priceUsd?.toBigDecimalOrNull() ?: asset.priceUSD() + val fee = customFeeValue ?: tipGas?.displayValue(transaction?.maxFeePerGas) ?: solanaFee?.stripTrailingZeros()?: btcFee?.stripTrailingZeros() ?: BigDecimal.ZERO if (fee == BigDecimal.ZERO) { FeeInfo( amount = "$fee", - fee = fee.multiply(asset.priceUSD()), + fee = fee.multiply(feePrice), isFree = isFeeWaived, onFreeClick = onFreeClick, ) } else { FeeInfo( - amount = "$fee ${asset?.symbol ?: ""}", - fee = fee.multiply(asset.priceUSD()), + amount = "$fee ${feeToken?.symbol ?: asset?.symbol ?: ""}", + fee = fee.multiply(feePrice), gasPrice = tipGas?.displayGas(transaction?.maxFeePerGas)?.toPlainString(), isFree = isFeeWaived, onFreeClick = onFreeClick, @@ -412,7 +419,11 @@ fun BrowserPage( Box(modifier = Modifier.height(20.dp)) } Box(modifier = Modifier.fillMaxWidth()) { - if (tipGas == null && data == null && step != WalletConnectBottomSheetDialogFragment.Step.Error) { + if (type != JsSignMessage.TYPE_GASLESS_TRANSFER && + tipGas == null && + data == null && + step != WalletConnectBottomSheetDialogFragment.Step.Error + ) { Column(modifier = Modifier.align(Alignment.BottomCenter)) { Box(modifier = Modifier.height(20.dp)) CircularProgressIndicator( 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 c16b032b68..cfb1e34a66 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 @@ -104,6 +104,8 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag const val ARGS_TO_ADDRESS = "args_to_address" const val ARGS_TO_USER = "args_to_user" const val ARGS_IS_FEE_FREE = "args_is_fee_free" + const val ARGS_FEE_AMOUNT = "args_fee_amount" + const val ARGS_FEE_TOKEN = "args_fee_token" fun newInstance( jsSignMessage: JsSignMessage, @@ -112,6 +114,8 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag amount: String? = null, token: Web3TokenItem? = null, chainToken: Web3TokenItem? = null, + feeAmount: String? = null, + feeToken: Web3TokenItem? = null, toAddress: String? = null, toUser: User? = null, isFeeWaived: Boolean = false, @@ -127,6 +131,8 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag amount?.let { putString(ARGS_AMOUNT, it) } token?.let { putParcelable(ARGS_TOKEN, it) } chainToken?.let { putParcelable(ARGS_CHAIN_TOKEN, it) } + feeAmount?.let { putString(ARGS_FEE_AMOUNT, it) } + feeToken?.let { putParcelable(ARGS_FEE_TOKEN, it) } toAddress?.let { putString(ARGS_TO_ADDRESS, it) } toUser?.let { putParcelable(ARGS_TO_USER, it) } putBoolean(ARGS_IS_FEE_FREE, isFeeWaived) @@ -150,6 +156,10 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private val toUser by lazy { requireArguments().getParcelableCompat(ARGS_TO_USER, User::class.java) } + private val feeAmount by lazy { requireArguments().getString(ARGS_FEE_AMOUNT) } + private val feeToken by lazy { + requireArguments().getParcelableCompat(ARGS_FEE_TOKEN, Web3TokenItem::class.java) + } private val isFeeWaived by lazy { requireArguments().getBoolean(ARGS_IS_FEE_FREE, false) } private val currentChain by lazy { token?.getChainFromName() ?: Web3Signer.currentChain @@ -177,14 +187,13 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag amount = requireArguments().getString(ARGS_AMOUNT) if (address.isBlank()) { lifecycleScope.launch { - address = if (signMessage.isEvmMessage()) { - Web3Signer.evmAddress - } else if (signMessage.isSolMessage()) { - Web3Signer.solanaAddress - } else if (signMessage.isBtcMessage()){ - Web3Signer.btcAddress - } else { - throw IllegalArgumentException("invalid signMessage type") + address = when { + signMessage.isEvmMessage() -> Web3Signer.evmAddress + signMessage.isSolMessage() -> Web3Signer.solanaAddress + signMessage.isBtcMessage() -> Web3Signer.btcAddress + signMessage.isGaslessTransfer() && currentChain == Chain.Solana -> Web3Signer.solanaAddress + signMessage.isGaslessTransfer() -> Web3Signer.evmAddress + else -> throw IllegalArgumentException("invalid signMessage type") } } } @@ -193,10 +202,10 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag @Composable override fun ComposeContent() { - if (signMessage.isSolMessage() && Web3Signer.solanaAddress.isBlank()) { + if ((signMessage.isSolMessage() || (signMessage.isGaslessTransfer() && currentChain == Chain.Solana)) && Web3Signer.solanaAddress.isBlank()) { toast(getString(R.string.not_support_network, currentChain.symbol)) dismiss() - } else if (signMessage.isEvmMessage() && Web3Signer.evmAddress.isBlank()) { + } else if ((signMessage.isEvmMessage() || (signMessage.isGaslessTransfer() && currentChain != Chain.Solana)) && Web3Signer.evmAddress.isBlank()) { toast(getString(R.string.not_support_network, currentChain.symbol)) dismiss() } else { @@ -205,6 +214,8 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag currentChain, amount, token, + feeAmount, + feeToken, toAddress, toUser, signMessage.type, @@ -291,6 +302,12 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private fun refreshEstimatedGasAndAsset(chain: Chain) { + if (signMessage.isGaslessTransfer()) { + lifecycleScope.launch { + asset = viewModel.refreshAsset(feeToken?.assetId ?: chain.getWeb3ChainId()) + } + return + } if (chain == Chain.Solana) { refreshSolana() return @@ -377,6 +394,17 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag try { step = Step.Loading errorInfo = null + customPinAction?.let { action -> + withContext(Dispatchers.Main.immediate) { + action(pin) + step = Step.Done + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + } + return@launch + } if (signMessage.type == JsSignMessage.TYPE_BTC_TRANSACTION) { val rawHex = signMessage.data ?: throw IllegalArgumentException("empty btc transaction hex") val priv = viewModel.getWeb3Priv(requireContext(), pin, Constants.ChainId.BITCOIN_CHAIN_ID) @@ -559,6 +587,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private var onRejectAction: (() -> Unit)? = null private var onDismissAction: ((Boolean) -> Unit)? = null private var onTxhash: ((String, String) -> Unit)? = null + private var customPinAction: (suspend (String) -> Unit)? = null fun getBiometricInfo() = BiometricInfo( @@ -582,6 +611,11 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private fun deposit() { dismiss() } + + fun setOnCustomPinAction(callback: suspend (String) -> Unit): BrowserWalletBottomSheetDialogFragment { + customPinAction = callback + return this + } } fun showGasCheckAndBrowserBottomSheetDialogFragment( @@ -623,6 +657,8 @@ fun showBrowserBottomSheetDialogFragment( amount: String? = null, token: Web3TokenItem? = null, chainToken: Web3TokenItem? = null, + feeAmount: String? = null, + feeToken: Web3TokenItem? = null, toAddress: String? = null, currentUrl: String? = null, currentTitle: String? = null, @@ -630,10 +666,11 @@ fun showBrowserBottomSheetDialogFragment( onDone: ((String?) -> Unit)? = null, onDismiss: ((Boolean) -> Unit)? = null, onTxhash: ((String, String) -> Unit)? = null, + onCustomPinAction: (suspend (String) -> Unit)? = null, toUser: User? = null, isFeeWaived: Boolean = false, ) { - val wcBottomSheet = BrowserWalletBottomSheetDialogFragment.newInstance(signMessage, currentUrl, currentTitle, amount, token, chainToken, toAddress, toUser, isFeeWaived) + val wcBottomSheet = BrowserWalletBottomSheetDialogFragment.newInstance(signMessage, currentUrl, currentTitle, amount, token, chainToken, feeAmount, feeToken, toAddress, toUser, isFeeWaived) onDismiss?.let { wcBottomSheet.setOnDismiss(onDismiss) } @@ -646,8 +683,11 @@ fun showBrowserBottomSheetDialogFragment( onTxhash?.let { wcBottomSheet.setOnTxhash(onTxhash) } + onCustomPinAction?.let { + wcBottomSheet.setOnCustomPinAction(onCustomPinAction) + } wcBottomSheet.showNow( fragmentActivity.supportFragmentManager, BrowserWalletBottomSheetDialogFragment.TAG, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt index b1907d467a..bd09b780d8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3 +import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -22,9 +23,18 @@ import one.mixin.android.api.handleMixinResponse import one.mixin.android.api.request.AccountUpdateRequest import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.request.web3.EstimateFeeResponse +import one.mixin.android.api.request.web3.GaslessFeeRequest +import one.mixin.android.api.request.web3.GaslessTxRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest +import one.mixin.android.api.request.web3.Web3RawTransactionRequest import one.mixin.android.api.response.PaymentStatus +import one.mixin.android.api.response.web3.GaslessFeeResponse +import one.mixin.android.api.response.web3.GaslessSponsorTransactionResponse +import one.mixin.android.api.response.web3.GaslessTxResponse import one.mixin.android.api.response.web3.StakeAccount +import one.mixin.android.api.response.web3.SubmitGaslessTxResponse import one.mixin.android.api.response.web3.WalletOutput +import one.mixin.android.crypto.CryptoWalletHelper import one.mixin.android.db.web3.vo.Web3Address import one.mixin.android.db.web3.vo.Web3RawTransaction import one.mixin.android.db.web3.vo.Web3TokenItem @@ -45,6 +55,7 @@ import one.mixin.android.tip.wc.WalletConnectV2 import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.tip.wc.internal.buildTipGas import one.mixin.android.tip.wc.internal.estimateFeeInBtc +import one.mixin.android.tip.Tip import one.mixin.android.ui.common.biometric.NftBiometricItem import one.mixin.android.ui.common.biometric.maxUtxoCount import one.mixin.android.ui.home.inscription.component.OwnerState @@ -78,6 +89,7 @@ class Web3ViewModel @Inject constructor( private val jobManager: MixinJobManager, private val web3Repository: Web3Repository, private val rpc: Rpc, + private val tip: Tip, ) : ViewModel() { var scrollOffset: Int = 0 @@ -205,6 +217,84 @@ class Web3ViewModel @Inject constructor( suspend fun findAssetItemById(assetId: String) = tokenRepository.findAssetItemById(assetId) + suspend fun getWeb3Priv( + context: Context, + pin: String, + chainId: String, + ): ByteArray { + val result = tip.getOrRecoverTipPriv(context, pin) + val spendKey = tip.getSpendPrivFromEncryptedSalt( + tip.getMnemonicFromEncryptedPreferences(context), + tip.getEncryptedSalt(context), + pin, + result.getOrThrow(), + ) + val privateKey = CryptoWalletHelper.getWeb3PrivateKey(context, spendKey, chainId) + return requireNotNull(privateKey) { "Failed to get private key" } + } + + suspend fun gaslessFee(request: GaslessFeeRequest): MixinResponse = + withContext(Dispatchers.IO) { + web3Repository.gaslessFee(request) + } + + suspend fun gaslessPrepare(request: GaslessTxRequest): MixinResponse = + withContext(Dispatchers.IO) { + web3Repository.gaslessTx(request) + } + + suspend fun submitGaslessTx(request: SubmitGaslessTxRequest): MixinResponse = + withContext(Dispatchers.IO) { + web3Repository.submitGaslessTx(request) + } + + suspend fun gaslessTransaction(id: String): MixinResponse = + withContext(Dispatchers.IO) { + web3Repository.gaslessTransaction(id) + } + + suspend fun postRawTx( + rawTx: String, + web3ChainId: String, + account: String, + to: String?, + assetId: String? = null, + feeType: String? = null, + rate: BigDecimal? = null, + ) = withContext(Dispatchers.IO) { + tokenRepository.postRawTx(Web3RawTransactionRequest(web3ChainId, rawTx, account, to, feeType), assetId, rate) + } + + suspend fun insertGaslessPendingTransaction( + sponsorTxId: String, + chainId: String, + account: String, + assetId: String, + amount: String, + fee: String, + to: String, + nonce: String, + createdAt: String, + updatedAt: String, + ) = withContext(Dispatchers.IO) { + tokenRepository.insertGaslessPendingTransaction(sponsorTxId, chainId, account, assetId, amount, fee, to, nonce, createdAt, updatedAt) + } + + suspend fun insertSignedPendingTransaction( + hash: String, + chainId: String, + account: String, + assetId: String, + amount: String, + fee: String, + to: String, + raw: String, + createdAt: String, + updatedAt: String, + ) = withContext(Dispatchers.IO) { + tokenRepository.insertSignedPendingTransaction(hash, chainId, account, assetId, amount, fee, to, raw, createdAt, updatedAt) + } + suspend fun ticker(assetId: String, offset: String?) = tokenRepository.ticker(assetId, offset) fun collectibles(sortOrder: SortOrder): LiveData> = @@ -486,6 +576,26 @@ class Web3ViewModel @Inject constructor( suspend fun transaction(hash: String, chainId: String) = tokenRepository.transaction(hash, chainId) + suspend fun replaceGaslessPendingTransactionHash( + walletId: String, + sponsorTxId: String, + broadcastTxHash: String, + chainId: String, + updatedAt: String, + ) = withContext(Dispatchers.IO) { + tokenRepository.replaceGaslessPendingTransactionHash(walletId, sponsorTxId, broadcastTxHash, chainId, updatedAt) + } + + suspend fun updateGaslessPendingTransactionStatus( + walletId: String, + hash: String, + chainId: String, + status: String, + updatedAt: String, + ) = withContext(Dispatchers.IO) { + tokenRepository.updateGaslessPendingTransactionStatus(walletId, hash, chainId, status, updatedAt) + } + suspend fun insertRawTransactionAndUpdateTransactionStatus( raw: Web3RawTransaction, hash: String, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt index df6f45c04b..251224d1e1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt @@ -301,6 +301,7 @@ fun SwapContent( quoteResult = quoteResult, quoteError = quoteError, isLoading = isLoading, + reviewing = reviewing, isButtonEnabled = isButtonEnabled, onButtonEnabledChange = { isButtonEnabled = it }, onReview = { onReview(it, fromToken!!, toToken!!, inputText) }, @@ -359,6 +360,7 @@ fun ReviewButton( quoteResult: QuoteResult?, quoteError: Throwable?, isLoading: Boolean, + reviewing: Boolean, isButtonEnabled: Boolean, onButtonEnabledChange: (Boolean) -> Unit, onReview: (QuoteResult) -> Unit, @@ -367,7 +369,7 @@ fun ReviewButton( scope: CoroutineScope ) { val checkBalance = checkBalance(inputText, fromBalance) - + val isBusy = isLoading || reviewing val hasError = quoteError != null Button( modifier = Modifier @@ -386,7 +388,7 @@ fun ReviewButton( } } }, - enabled = quoteResult != null && !hasError && !isLoading && checkBalance == true, + enabled = quoteResult != null && !hasError && !isBusy && checkBalance == true, colors = ButtonDefaults.outlinedButtonColors( backgroundColor = if (quoteResult != null && !hasError && checkBalance == true) { MixinAppTheme.colors.accent @@ -402,7 +404,7 @@ fun ReviewButton( focusedElevation = 0.dp, ), ) { - if (isLoading) { + if (isBusy) { CircularProgressIndicator( modifier = Modifier.size(18.dp), color = if (quoteResult != null && !hasError && checkBalance == true) { 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 2b6fa67ac2..3925d23fdc 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 @@ -41,6 +41,7 @@ import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.api.request.web3.SwapRequest +import one.mixin.android.api.request.web3.GaslessTxRequest import one.mixin.android.api.response.CreateLimitOrderResponse import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.SwapResponse @@ -49,6 +50,8 @@ import one.mixin.android.api.response.web3.Swappable import one.mixin.android.api.response.web3.sortByKeywordAndBalance import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.db.web3.vo.Web3TokenItem +import one.mixin.android.db.web3.vo.Web3TokenFeeItem +import one.mixin.android.db.web3.vo.buildTransaction import one.mixin.android.event.BadgeEvent import one.mixin.android.extension.addToList import one.mixin.android.extension.alertDialogBuilder @@ -68,7 +71,7 @@ import one.mixin.android.extension.withArgs import one.mixin.android.job.MixinJobManager import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment -import one.mixin.android.ui.home.web3.GasCheckBottomSheetDialogFragment +import one.mixin.android.ui.home.web3.Web3ViewModel import one.mixin.android.ui.home.web3.trade.perps.AllPositionsFragment import one.mixin.android.ui.home.web3.trade.perps.PerpetualGuideBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity @@ -78,6 +81,7 @@ import one.mixin.android.ui.wallet.AllOrdersFragment import one.mixin.android.ui.wallet.DepositFragment import one.mixin.android.ui.wallet.LimitTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment +import one.mixin.android.ui.wallet.transfer.TransferWeb3BalanceErrorBottomSheetDialogFragment import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.ErrorHandler import one.mixin.android.util.GsonHelper @@ -88,6 +92,7 @@ import one.mixin.android.web3.js.Web3Signer import one.mixin.android.web3.receive.Web3AddressFragment import one.mixin.android.web3.swap.SwapTokenListBottomSheetDialogFragment import timber.log.Timber +import java.math.BigDecimal import javax.inject.Inject @AndroidEntryPoint @@ -159,6 +164,7 @@ class TradeFragment : BaseFragment() { lateinit var jobManager: MixinJobManager private val swapViewModel by viewModels() + private val web3ViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -329,15 +335,27 @@ class TradeFragment : BaseFragment() { onReview = { quote, from, to, amount -> AnalyticsTracker.trackTradePreview() this@apply.hideKeyboard() + reviewing = true lifecycleScope.launch { - handleReview(quote, from, to, amount, navController) + runCatching { + handleReview(quote, from, to, amount, navController) + }.onFailure { + reviewing = false + toast(ErrorHandler.getErrorMessage(it)) + } } }, onLimitReview = { from, to, order -> AnalyticsTracker.trackTradePreview() this@apply.hideKeyboard() + reviewing = true lifecycleScope.launch { - openLimitTransfer(from, to, order) + runCatching { + openLimitTransfer(from, to, order) + }.onFailure { + reviewing = false + toast(ErrorHandler.getErrorMessage(it)) + } } }, onDeposit = { token -> @@ -650,11 +668,13 @@ class TradeFragment : BaseFragment() { if (!inMixin()) { val address = swapViewModel.getAddressesByChainId(Web3Signer.currentWalletId, to.chain.chainId) if (address == null){ + reviewing = false toast(R.string.Alert_Not_Support) return } val fromAddress = swapViewModel.getAddressesByChainId(Web3Signer.currentWalletId, from.chain.chainId) if (fromAddress == null){ + reviewing = false toast(R.string.Alert_Not_Support) return } @@ -688,41 +708,28 @@ class TradeFragment : BaseFragment() { false } ) - if (resp == null) return - if (inMixin()) { - openSwapTransfer(resp, from, to) - } else { - openSwapTransfer(resp, from, to) + if (resp == null) { + reviewing = false + return } + if (!ensureWeb3FeeSufficient(from, resp.depositDestination, quote.inAmount, allowGasless = true)) { + reviewing = false + return + } + openSwapTransfer(resp, from, to) } private fun openSwapTransfer(swapResult: SwapResponse, from: SwapToken, to: SwapToken) { - if (from.chain.chainId == Constants.ChainId.Solana || from.chain.chainId == Constants.ChainId.BITCOIN_CHAIN_ID || inMixin()) { - AnalyticsTracker.trackTradePreview() - SwapTransferBottomSheetDialogFragment.newInstance(swapResult, from, to).apply { - setOnDone { - initialAmount = null - lastOrderTime = System.currentTimeMillis() - } - setOnDestroy { - reviewing = false - } - }.showNow(parentFragmentManager, SwapTransferBottomSheetDialogFragment.TAG) - reviewing = true - } else { - GasCheckBottomSheetDialogFragment.newInstance(swapResult, from, to).apply { - setOnDone { - initialAmount = null - lastOrderTime = System.currentTimeMillis() - } - setOnDestroy { - reviewing = false - } - }.showNow( - parentFragmentManager, - GasCheckBottomSheetDialogFragment.TAG - ) - } + AnalyticsTracker.trackTradePreview() + SwapTransferBottomSheetDialogFragment.newInstance(swapResult, from, to).apply { + setOnDone { + initialAmount = null + lastOrderTime = System.currentTimeMillis() + } + setOnDestroy { + reviewing = false + } + }.showNow(parentFragmentManager, SwapTransferBottomSheetDialogFragment.TAG) } private suspend fun openLimitTransfer(from: SwapToken, to: SwapToken, order: CreateLimitOrderResponse) { @@ -731,15 +738,21 @@ class TradeFragment : BaseFragment() { if (!inMixin()) { val address = swapViewModel.getAddressesByChainId(Web3Signer.currentWalletId, to.chain.chainId) if (address == null){ + reviewing = false toast(R.string.Alert_Not_Support) return } val fromAddress = swapViewModel.getAddressesByChainId(Web3Signer.currentWalletId, from.chain.chainId) if (fromAddress == null){ + reviewing = false toast(R.string.Alert_Not_Support) return } } + if (!ensureWeb3FeeSufficient(from, order.depositDestination, order.order.payAmount, allowGasless = false)) { + reviewing = false + return + } LimitTransferBottomSheetDialogFragment.newInstance(order, from, to, senderWalletId).apply { setOnDone { initialAmount = null @@ -749,7 +762,111 @@ class TradeFragment : BaseFragment() { reviewing = false } }.showNow(parentFragmentManager, LimitTransferBottomSheetDialogFragment.TAG) - reviewing = true + } + + private suspend fun ensureWeb3FeeSufficient( + from: SwapToken, + destination: String?, + amount: String, + allowGasless: Boolean, + ): Boolean { + if (inMixin()) return true + val walletId = Web3Signer.currentWalletId + val token = swapViewModel.getTokenByWalletAndAssetId(walletId, from.assetId) + if (token == null) return true + val toAddress = destination + if (toAddress == null) return true + val fromAddress = when (token.chainId) { + Constants.ChainId.SOLANA_CHAIN_ID -> Web3Signer.solanaAddress + Constants.ChainId.BITCOIN_CHAIN_ID -> { + val btcAddress = swapViewModel.getAddressesByChainId(walletId, Constants.ChainId.BITCOIN_CHAIN_ID)?.destination + if (btcAddress == null) return true + btcAddress + } + else -> Web3Signer.evmAddress + } + + if (allowGasless && token.chainId != Constants.ChainId.BITCOIN_CHAIN_ID) { + val gaslessPrepared = runCatching { + web3ViewModel.gaslessPrepare( + GaslessTxRequest( + from = fromAddress, + to = toAddress, + assetId = token.assetId, + amount = amount, + feeAssetId = token.assetId, + chainId = token.chainId, + ) + ) + }.getOrNull()?.data + if (gaslessPrepared != null) { + return true + } + } + + val chainToken = swapViewModel.web3TokenItemById(walletId, token.chainId) + if (chainToken == null) return true + val chainBalance = chainToken.balance.toBigDecimalOrNull() + if (chainBalance == null) return true + val transferAmount = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val sameAssetFee = token.assetId == chainToken.assetId && token.chainId == chainToken.chainId + val fee = estimateWeb3Fee(token, fromAddress, toAddress, amount) + if (fee == null) { + val insufficientByFullBalanceFallback = sameAssetFee && transferAmount >= chainBalance + if (!insufficientByFullBalanceFallback) { + return true + } + TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance( + Web3TokenFeeItem( + token = chainToken, + amount = transferAmount, + fee = BigDecimal.ZERO, + ) + ).showNow(parentFragmentManager, TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG) + return false + } + val requiredBalance = if (sameAssetFee) transferAmount + fee else fee + + if (requiredBalance <= chainBalance) { + return true + } + + TransferWeb3BalanceErrorBottomSheetDialogFragment.newInstance( + Web3TokenFeeItem( + token = chainToken, + amount = if (sameAssetFee) transferAmount else BigDecimal.ZERO, + fee = fee, + ) + ).showNow(parentFragmentManager, TransferWeb3BalanceErrorBottomSheetDialogFragment.TAG) + return false + } + + private suspend fun estimateWeb3Fee( + token: Web3TokenItem, + fromAddress: String, + toAddress: String, + amount: String, + ): BigDecimal? { + return if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { + val localUtxos = web3ViewModel.outputsByAddress(fromAddress, Constants.ChainId.BITCOIN_CHAIN_ID) + val zeroFeeTx = token.buildTransaction( + rpc = rpc, + fromAddress = fromAddress, + toAddress = toAddress, + v = amount, + localUtxos = localUtxos, + rate = BigDecimal.ONE, + ) + web3ViewModel.calcFee(token, zeroFeeTx, fromAddress).fee + } else { + val transaction = token.buildTransaction( + rpc = rpc, + fromAddress = fromAddress, + toAddress = toAddress, + v = amount, + ) + web3ViewModel.calcFee(token, transaction, fromAddress).fee + } } private suspend fun initFromTo() { 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 d5d41aaa00..4a6d6a9e15 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 @@ -516,6 +516,7 @@ fun FeeInfo( fee: BigDecimal, gasPrice: String? = null, isFree: Boolean = false, + isLoading: Boolean = false, onFreeClick: (() -> Unit)? = null, ) { Column( @@ -535,43 +536,50 @@ fun FeeInfo( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = amount, - color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, - style = TextStyle(textDecoration = if (isFree) TextDecoration.LineThrough else TextDecoration.None), - ) - if (gasPrice != null) { - Box(modifier = Modifier.width(6.dp)) - Text( - text = "($gasPrice Gwei)", - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - ) - } - if (isFree) { - Spacer(modifier = Modifier.width(8.dp)) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MixinAppTheme.colors.accent, + ) + } else { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(id = R.string.FREE), - color = Color.White, - fontSize = 10.sp, - lineHeight = 10.sp, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(MixinAppTheme.colors.accent) - .padding(horizontal = 3.dp, vertical = 1.dp) - .let { m -> if (onFreeClick != null) m.clickable { onFreeClick.invoke() } else m } + text = amount, + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, + style = TextStyle(textDecoration = if (isFree) TextDecoration.LineThrough else TextDecoration.None), ) + if (gasPrice != null) { + Box(modifier = Modifier.width(6.dp)) + Text( + text = "($gasPrice Gwei)", + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + } + if (isFree) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.FREE), + color = Color.White, + fontSize = 10.sp, + lineHeight = 10.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MixinAppTheme.colors.accent) + .padding(horizontal = 3.dp, vertical = 1.dp) + .let { m -> if (onFreeClick != null) m.clickable { onFreeClick.invoke() } else m } + ) + } } + Box(modifier = Modifier.height(4.dp)) + Text( + text = fee.currencyFormat(), + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) } - Box(modifier = Modifier.height(4.dp)) - Text( - text = fee.currencyFormat(), - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp, - ) } Box(modifier = Modifier) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletFragment.kt index e08fe296f5..1edf7e935a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletFragment.kt @@ -377,7 +377,9 @@ class ClassicWalletFragment : BaseFragment(R.layout.fragment_privacy_wallet), He } fun update() { - jobManager.addJobInBackground(RefreshWeb3TransactionsJob()) + if (walletId.isEmpty().not()) { + jobManager.addJobInBackground(RefreshWeb3TransactionsJob(walletId)) + } if (walletId.isEmpty().not()) { jobManager.addJobInBackground(RefreshWeb3TokenJob(walletId = walletId)) } 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 b7699fc325..7f11f34b6a 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 @@ -16,6 +16,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import com.google.gson.JsonElement import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -23,15 +24,22 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import one.mixin.android.BuildConfig import one.mixin.android.Constants import one.mixin.android.R +import one.mixin.android.api.request.web3.GaslessFeeRequest +import one.mixin.android.api.request.web3.GaslessTxRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.response.PaymentStatus +import one.mixin.android.api.response.web3.EthGaslessTxPayload import one.mixin.android.databinding.FragmentInputBinding import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.buildTransaction import one.mixin.android.db.web3.vo.getChainSymbolFromName import one.mixin.android.db.web3.vo.isNativeSolToken +import one.mixin.android.extension.base64Encode import one.mixin.android.extension.clickVibrate +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.formatPublicKey import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.hideKeyboard @@ -43,9 +51,12 @@ import one.mixin.android.extension.numberFormat12 import one.mixin.android.extension.numberFormat2 import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.openUrl +import one.mixin.android.extension.putLong +import one.mixin.android.extension.stripAmountZero import one.mixin.android.extension.textColor import one.mixin.android.extension.tickVibrate import one.mixin.android.extension.toast +import one.mixin.android.extension.updatePinCheck import one.mixin.android.extension.viewDestroyed import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshWeb3BitCoinJob @@ -71,6 +82,7 @@ import one.mixin.android.ui.home.web3.showBrowserBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.SwapActivity import one.mixin.android.ui.wallet.transfer.TransferBottomSheetDialogFragment import one.mixin.android.util.ErrorHandler +import one.mixin.android.util.GsonHelper import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.analytics.AnalyticsTracker.TradeSource import one.mixin.android.util.analytics.AnalyticsTracker.TradeWallet @@ -80,12 +92,17 @@ import one.mixin.android.vo.Fiats import one.mixin.android.vo.User import one.mixin.android.vo.safe.TokenItem import one.mixin.android.vo.safe.TokensExtra +import one.mixin.android.vo.safe.toWeb3TokenItem 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 one.mixin.android.web3.send.InsufficientBtcBalanceException import one.mixin.android.widget.Keyboard +import org.sol4k.Base58 import org.sol4k.PublicKey +import org.sol4k.Constants.SIGNATURE_LENGTH +import org.sol4kt.VersionedTransactionCompat import timber.log.Timber import java.math.BigDecimal import java.math.RoundingMode @@ -97,6 +114,7 @@ import kotlin.math.max class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionClicker { companion object { const val TAG = "InputFragment" + private const val GASLESS_EIP7702_AUTHORIZED_ADDRESS = "0xe6cae83bde06e4c305530e199d7217f42808555b" const val ARGS_TO_ADDRESS = "args_to_address" const val ARGS_FROM_ADDRESS = "args_from_address" const val ARGS_TO_ADDRESS_TAG = "args_to_address_tag" @@ -365,6 +383,41 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC AddFeeBottomSheetDialogFragment.TAG ) } + } else if (transferType == TransferType.WEB3 && shouldUseGaslessFlow() && currentGaslessFee != null) { // Insufficient gasless fee Balance + val gaslessToken = gaslessFeeToken ?: currentGaslessFee!!.token.toWeb3TokenItem(requireNotNull(web3Token).walletId) + AddFeeBottomSheetDialogFragment.newInstance(gaslessToken) + .apply { + onWeb3Action = { type, t -> + if (type == AddFeeBottomSheetDialogFragment.ActionType.SWAP) { + AnalyticsTracker.trackTradeStart(TradeWallet.WEB3, TradeSource.FEE) + SwapActivity.show( + requireActivity(), + input = Constants.AssetId.USDT_ASSET_ETH_ID, + output = t.assetId, + walletId = Web3Signer.currentWalletId, + inMixin = false + ) + } else if (type == AddFeeBottomSheetDialogFragment.ActionType.DEPOSIT) { + val address = + when (web3Token?.chainId) { + Constants.ChainId.SOLANA_CHAIN_ID -> Web3Signer.solanaAddress + in Constants.Web3EvmChainIds -> Web3Signer.evmAddress + else -> null + } + this@InputFragment.view?.navigate( + R.id.action_input_fragment_to_web3_address_fragment, + Bundle().apply { + putString("address", address) + putParcelable("web3_token", t) + putBoolean("args_hide_network_switch", true) + } + ) + } + } + }.showNow( + parentFragmentManager, + AddFeeBottomSheetDialogFragment.TAG + ) } else if (gas != null && chainToken != null) { // Insufficient gas Balance AddFeeBottomSheetDialogFragment.newInstance(chainToken!!) .apply { @@ -530,12 +583,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC val token = requireNotNull(web3Token) val fromAddress = requireNotNull(fromAddress) val toAddress = requireNotNull(toAddress) - val amount = - if (isReverse) { - binding.minorTv.text.toString().split(" ")[1].replace(",", "") - } else { - v - } + val amount = currentInputAmount() lifecycleScope.launch( CoroutineExceptionHandler { _, error -> Timber.e("Error: ${error.message}") @@ -543,6 +591,40 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC alertDialog.dismiss() }, ) { + if (shouldUseGaslessFlow()) { + if (!isGaslessFeeEnough(amount)) { + binding.insufficientFeeBalance.isVisible = true + binding.insufficientBalance.isVisible = false + binding.insufficientFunds.isVisible = false + binding.addTv.text = "${getString(R.string.Add)} ${currentGaslessFee?.token?.symbol ?: ""}" + return@launch + } + val previewFeeToken = gaslessFeeToken ?: currentGaslessFee?.token?.toWeb3TokenItem(token.walletId) + showBrowserBottomSheetDialogFragment( + requireActivity(), + JsSignMessage( + callbackId = 0L, + type = JsSignMessage.TYPE_GASLESS_TRANSFER, + ), + amount = amount, + token = token, + chainToken = chainToken, + feeAmount = currentGaslessFee?.fee, + feeToken = previewFeeToken, + toAddress = toAddress, + toUser = user, + onCustomPinAction = { pin -> + submitGaslessTransfer(pin) + }, + onDismiss = { isDone -> + if (isDone) { + handleSuccessfulWeb3Transfer() + } + }, + isFeeWaived = isFeeWaived, + ) + return@launch + } if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { val minBtcAmount = BigDecimal("0.00001") // 1,000 sat val inputAmount: BigDecimal = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -576,41 +658,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC }, onDismiss = { isDone -> if (isDone) { - val navController = findNavController() - val backStackEntryCount = parentFragmentManager.backStackEntryCount - - // Check if current fragment is the start destination of navigation - val currentDestination = navController.currentDestination?.id - val startDestination = navController.graph.startDestinationId - val isStartDestination = currentDestination == startDestination || backStackEntryCount <= 1 - - if (isStartDestination) { - // If InputFragment or TransferDestinationInputFragment is start destination, exit activity - requireActivity().finish() - } else { - // Otherwise, navigate back to TransferDestinationInputFragment and pop it - parentFragmentManager.apply { - // Pop all fragments back to TransferDestinationInputFragment - var foundTransferDestFragment = false - val fragmentCount = backStackEntryCount - for (i in 0 until fragmentCount) { - val topFragment = fragments.lastOrNull() - if (topFragment is TransferDestinationInputFragment) { - // Found TransferDestinationInputFragment, pop it too - popBackStackImmediate() - foundTransferDestFragment = true - break - } else { - popBackStackImmediate() - } - } - - // If no TransferDestinationInputFragment found in stack, just pop all - if (!foundTransferDestFragment) { - popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - } - } + handleSuccessfulWeb3Transfer() } } ) @@ -724,7 +772,6 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } } } - else -> {} } } } @@ -789,6 +836,191 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC updateUI() } + private fun currentInputAmount(): String { + return if (isReverse) { + binding.minorTv.text.toString().split(" ").getOrNull(1)?.replace(",", "") ?: "0" + } else { + v + } + } + + private fun shouldOfferLegacyWeb3FeeOption(): Boolean { + return BuildConfig.DEBUG + } + + private fun hasNativeGasIssue(amount: String): Boolean { + val token = web3Token ?: return false + val chainAsset = chainToken ?: return true + val gasAmount = gas ?: return true + val chainBalance = chainAsset.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO + return if (token.assetId == chainAsset.assetId) { + chainBalance < gasAmount.add(amount.toBigDecimalOrNull() ?: BigDecimal.ZERO) + } else { + chainBalance < gasAmount + } + } + + private fun isGaslessFeeEnough(amount: String): Boolean { + val transferToken = web3Token ?: return false + val fee = currentGaslessFee ?: return false + val feeTokenBalance = gaslessFeeToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val transferBalance = transferToken.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO + val inputAmount = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val feeAmount = fee.fee.toBigDecimalOrNull() ?: BigDecimal.ZERO + return if (fee.token.assetId == transferToken.assetId) { + transferBalance >= inputAmount.add(feeAmount) + } else { + transferBalance >= inputAmount && feeTokenBalance >= feeAmount + } + } + + private fun hasGaslessFeeSelection(): Boolean { + return transferType == TransferType.WEB3 && + currentGaslessFee != null + } + + private fun isLegacyWeb3FeeSelection(): Boolean { + return currentGaslessFee?.source == NetworkFee.Source.LEGACY_WEB3 + } + + private fun nativeWeb3FeeOption(): NetworkFee? { + val nativeToken = chainToken ?: return null + val nativeGas = gas ?: return null + return NetworkFee( + token = nativeToken.toTokenItem(), + fee = nativeGas.toPlainString(), + source = NetworkFee.Source.LEGACY_WEB3, + ) + } + + private fun web3FeeOptions(): List { + val options = mutableListOf() + if (shouldOfferLegacyWeb3FeeOption()) { + nativeWeb3FeeOption()?.let(options::add) + } + options.addAll(gaslessFees) + return options + } + + private fun syncSelectedWeb3Fee() { + val options = web3FeeOptions() + if (options.isEmpty()) { + currentGaslessFee = null + return + } + val matchedOption = currentGaslessFee?.selectionKey?.let { selectionKey -> + options.firstOrNull { it.selectionKey == selectionKey } + } + val nextSelection = when { + matchedOption != null -> matchedOption + !hasManuallySelectedWeb3Fee && shouldOfferLegacyWeb3FeeOption() -> nativeWeb3FeeOption() ?: options.first() + !hasManuallySelectedWeb3Fee -> options.first() + else -> options.first() + } + if (currentGaslessFee != nextSelection) { + currentGaslessFee = nextSelection + } + } + + private fun shouldUseGaslessFlow(): Boolean { + return hasGaslessFeeSelection() && + !isLegacyWeb3FeeSelection() + } + + private fun updateWeb3AvailableBalance() { + val transferToken = web3Token ?: return + val displayBalance = + when { + shouldUseGaslessFlow() && currentGaslessFee?.token?.assetId == transferToken.assetId -> { + (tokenBalance.toBigDecimalOrNull() ?: BigDecimal.ZERO) + .subtract(currentGaslessFee?.fee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) + .max(BigDecimal.ZERO) + } + transferToken.assetId == chainToken?.assetId -> { + (tokenBalance.toBigDecimalOrNull() ?: BigDecimal.ZERO) + .subtract(gas ?: BigDecimal.ZERO) + .max(BigDecimal.ZERO) + } + else -> tokenBalance.toBigDecimalOrNull() ?: BigDecimal.ZERO + } + val balanceText = + if (transferToken.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { + displayBalance.numberFormat8() + } else { + displayBalance.numberFormat12() + } + binding.balanceTv.text = getString(R.string.available_balance, "$balanceText $tokenSymbol") + } + + private fun updateWeb3FeeDisplay() { + val token = web3Token ?: return + if (binding.loadingProgressBar.isVisible) return + val feeOptions = web3FeeOptions() + if (hasGaslessFeeSelection()) { + val selectedGaslessFee = currentGaslessFee + binding.contentTextView.text = selectedGaslessFee?.let { + "${it.fee.numberFormat8()} ${it.token.symbol}" + } ?: "" + binding.insufficientFeeBalance.text = getString(R.string.insufficient_gas, selectedGaslessFee?.token?.symbol ?: token.getChainSymbolFromName()) + if (feeOptions.size > 1) { + binding.iconImageView.isVisible = true + binding.iconImageView.setImageResource(R.drawable.ic_keyboard_arrow_down) + binding.infoLinearLayout.setOnClickListener { + NetworkFeeBottomSheetDialogFragment.newInstance( + ArrayList(feeOptions), + currentGaslessFee?.selectionKey, + ).apply { + callback = { networkFee -> + hasManuallySelectedWeb3Fee = true + currentGaslessFee = networkFee + binding.insufficientFeeBalance.isVisible = false + dismiss() + } + }.show(parentFragmentManager, NetworkFeeBottomSheetDialogFragment.TAG) + } + } else { + binding.iconImageView.isVisible = false + binding.infoLinearLayout.setOnClickListener(null) + } + return + } + binding.iconImageView.isVisible = false + binding.infoLinearLayout.setOnClickListener { } + binding.insufficientFeeBalance.text = getString(R.string.insufficient_gas, chainToken?.symbol ?: token.getChainSymbolFromName()) + binding.contentTextView.text = "${gas?.numberFormat8()} ${chainToken?.symbol ?: token.getChainSymbolFromName()}" + } + + private fun handleSuccessfulWeb3Transfer() { + val navController = findNavController() + val backStackEntryCount = parentFragmentManager.backStackEntryCount + val currentDestination = navController.currentDestination?.id + val startDestination = navController.graph.startDestinationId + val isStartDestination = currentDestination == startDestination || backStackEntryCount <= 1 + + if (isStartDestination) { + requireActivity().finish() + return + } + parentFragmentManager.apply { + var foundTransferDestFragment = false + val fragmentCount = backStackEntryCount + for (i in 0 until fragmentCount) { + val topFragment = fragments.lastOrNull() + if (topFragment is TransferDestinationInputFragment) { + popBackStackImmediate() + foundTransferDestFragment = true + break + } else { + popBackStackImmediate() + } + } + + if (!foundTransferDestFragment) { + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + } + } + private var isFeeWaived = false private fun renderTitle(toAddress: String, tag: String? = null) { @@ -925,12 +1157,21 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC continueVa.isEnabled = false addTv.text = "${getString(R.string.Add)} ${currentFee?.token?.symbol ?: ""}" continueTv.textColor = requireContext().getColor(R.color.wallet_text_gray) - } else if ( - web3Token != null && (chainToken == null || gas == null || (chainToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO) < gas || - (web3Token?.assetId == chainToken?.assetId && (gas ?: BigDecimal.ZERO).add(BigDecimal(v)) > (web3Token?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO))) - ) { - insufficientFeeBalance.isVisible = gas != null - addTv.text = "${getString(R.string.Add)} ${chainToken?.symbol ?: ""}" + } else if (web3Token != null && shouldUseGaslessFlow()) { + val gaslessEnough = isGaslessFeeEnough(v) + insufficientFeeBalance.isVisible = !gaslessEnough + addTv.text = if (gaslessEnough) { + "" + } else { + "${getString(R.string.Add)} ${currentGaslessFee?.token?.symbol.orEmpty()}" + } + insufficientBalance.isVisible = false + insufficientFunds.isVisible = false + continueVa.isEnabled = gaslessEnough + continueTv.textColor = requireContext().getColor(if (gaslessEnough) R.color.white else R.color.wallet_text_gray) + } else if (web3Token != null && hasNativeGasIssue(v)) { + insufficientFeeBalance.isVisible = true + addTv.text = "${getString(R.string.Add)} ${(chainToken?.symbol ?: web3Token?.getChainSymbolFromName()).orEmpty()}" insufficientBalance.isVisible = false insufficientFunds.isVisible = false continueVa.isEnabled = false @@ -954,6 +1195,10 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } updatePrimarySize() + if (transferType == TransferType.WEB3) { + updateWeb3AvailableBalance() + updateWeb3FeeDisplay() + } applyFeeUi() } @@ -1056,7 +1301,13 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private fun updateAddText() { - if (gas != null && chainToken != null) { + if (transferType == TransferType.WEB3 && shouldUseGaslessFlow()) { + if (!isGaslessFeeEnough(currentInputAmount())) { + binding.addTv.text = "${getString(R.string.Add)} ${currentGaslessFee?.token?.symbol ?: ""}" + } else { + binding.addTv.text = "" + } + } else if (gas != null && chainToken != null) { if ((chainToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO) < gas) { binding.addTv.text = "${getString(R.string.Add)} ${chainToken?.symbol ?: ""}" } else { @@ -1161,6 +1412,9 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private fun valueClick(percentageOfBalance: BigDecimal) { val baseValue = when { + shouldUseGaslessFlow() && web3Token?.assetId == currentGaslessFee?.token?.assetId -> { + BigDecimal(tokenBalance).subtract(currentGaslessFee?.fee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) + } web3Token != null && web3Token?.assetId == chainToken?.assetId -> { if (gas == null) { if (!dialog.isShowing) { @@ -1210,7 +1464,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC refreshFee(token!!) } transferType == TransferType.WEB3 -> { - refreshGas(web3Token!!) + refreshWeb3Fees(web3Token!!) } transferType == TransferType.BIOMETRIC_ITEM && assetBiometricItem is WithdrawBiometricItem -> { refreshFee(token!!) @@ -1245,12 +1499,31 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private var feeTokensExtra: TokensExtra? = null + private val gaslessFees: ArrayList = arrayListOf() + private var currentGaslessFee: NetworkFee? = null + set(value) { + field = value + refreshGaslessFeeToken(value?.token?.assetId) + } + private var gaslessFeeToken: Web3TokenItem? = null + private var hasManuallySelectedWeb3Fee = false + private fun refreshFeeTokenExtra(tokenId: String?) = lifecycleScope.launch { feeTokensExtra = if (tokenId == null) null else web3ViewModel.findTokensExtra(tokenId) updateUI() } + private fun refreshGaslessFeeToken(tokenId: String?) = lifecycleScope.launch { + gaslessFeeToken = if (tokenId == null || web3Token == null) { + null + } else { + web3ViewModel.findWeb3TokenItems(requireNotNull(web3Token).walletId).firstOrNull { it.assetId == tokenId } + } + updateWeb3FeeDisplay() + updateUI() + } + private var gas: BigDecimal? = null private var rate: BigDecimal? = null private var miniFee: String? = null @@ -1259,10 +1532,14 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private var lastBtcFeeAmount: String? = null private var isAdjustingBtcAmount: Boolean = false + private fun setFeeLoading(isLoading: Boolean) { + binding.loadingProgressBar.isVisible = isLoading + binding.contentTextView.isVisible = !isLoading + } + private suspend fun refreshFee(t: TokenItem) { val toAddress = toAddress?: return - binding.loadingProgressBar.isVisible = true - binding.contentTextView.isVisible = false + setFeeLoading(true) val feeResponse = runCatching { web3ViewModel.getFees(t.assetId, toAddress) }.getOrNull() if (feeResponse == null) { delay(3000) @@ -1311,8 +1588,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC dialog.dismiss() } } - binding.contentTextView.isVisible = true - binding.loadingProgressBar.isVisible = false + setFeeLoading(false) } private fun prepareCheck(item: BiometricItem) { @@ -1428,10 +1704,22 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC }.show(parentFragmentManager, TransferBottomSheetDialogFragment.TAG) } + private suspend fun refreshWeb3Fees(t: Web3TokenItem) { + setFeeLoading(true) + try { + refreshGas(t) + refreshGaslessFees(t) + syncSelectedWeb3Fee() + } finally { + setFeeLoading(false) + updateWeb3FeeDisplay() + applyFeeUi() + updateUI() + } + } + private suspend fun refreshGas(t: Web3TokenItem) { val toAddress = toAddress?: return - binding.loadingProgressBar.isVisible = true - binding.contentTextView.isVisible = false val fromAddress = fromAddress ?: return if (t.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { jobManager.addJobInBackground(RefreshWeb3BitCoinJob(t.walletId)) @@ -1493,10 +1781,6 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC ) } updateUI() - binding.insufficientFeeBalance.text = - getString(R.string.insufficient_gas, chainToken?.symbol) - - binding.contentTextView.text = "${gas?.numberFormat8()} ${chainToken?.symbol ?: t.getChainSymbolFromName()}" if (dialog.isShowing) { dialog.dismiss() v = if (isReverse) { @@ -1508,10 +1792,176 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC updateUI() } } - binding.iconImageView.isVisible = false - binding.contentTextView.isVisible = true - binding.loadingProgressBar.isVisible = false - applyFeeUi() + } + + private suspend fun refreshGaslessFees(t: Web3TokenItem) { + val fromAddress = fromAddress ?: return + val toAddress = toAddress ?: return + val response = runCatching { + web3ViewModel.gaslessFee( + GaslessFeeRequest( + from = fromAddress, + to = toAddress, + assetId = t.assetId, + chainId = t.chainId, + ), + ) + }.getOrNull() ?: return + if (!response.isSuccess || response.data == null) return + + val feeItems = response.data!!.fees.mapNotNull { estimate -> + val asset = web3ViewModel.findOrSyncAsset(estimate.assetId) ?: return@mapNotNull null + NetworkFee( + token = asset, + fee = estimate.amount, + source = NetworkFee.Source.GASLESS, + ) + } + gaslessFees.clear() + gaslessFees.addAll(feeItems) + } + + private suspend fun submitGaslessTransfer(pin: String) { + val token = requireNotNull(web3Token) + val fromAddress = requireNotNull(fromAddress) + val toAddress = requireNotNull(toAddress) + val fee = requireNotNull(currentGaslessFee) { "gasless fee asset is required" } + val amount = currentInputAmount() + val response = web3ViewModel.gaslessPrepare( + GaslessTxRequest( + from = fromAddress, + to = toAddress, + assetId = token.assetId, + amount = amount, + feeAssetId = fee.token.assetId, + chainId = token.chainId, + ), + ) + if (!response.isSuccess || response.data == null) { + throw IllegalStateException(response.errorDescription) + } + val payload = response.data!! + val privateKey = web3ViewModel.getWeb3Priv(requireContext(), pin, token.chainId) + when (token.chainId) { + Constants.ChainId.SOLANA_CHAIN_ID -> submitSolanaGaslessTransfer( + token = token, + amount = amount, + fromAddress = fromAddress, + toAddress = toAddress, + payload = payload.payload, + privateKey = privateKey, + ) + in Constants.Web3EvmChainIds -> submitEvmGaslessTransfer( + token = token, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + chainId = payload.chainId, + payload = payload.payload, + privateKey = privateKey, + ) + else -> throw IllegalArgumentException("Gasless is not supported for ${token.chainId}") + } + requireContext().defaultSharedPreferences.putLong(Constants.BIOMETRIC_PIN_CHECK, System.currentTimeMillis()) + requireContext().updatePinCheck() + } + + 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, + fee = requireNotNull(currentGaslessFee).fee.stripAmountZero(), + 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, + fee = requireNotNull(currentGaslessFee).fee.stripAmountZero(), + to = toAddress, + nonce = ethPayload.userOperation.nonce, + createdAt = now, + updatedAt = now, + ) } private val dialog by lazy { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/NetworkFeeBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/NetworkFeeBottomSheetDialogFragment.kt index ce7470d8b3..6bce58bcea 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/NetworkFeeBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/NetworkFeeBottomSheetDialogFragment.kt @@ -16,6 +16,7 @@ import one.mixin.android.databinding.FragmentNetworkFeeBottomSheetBinding import one.mixin.android.databinding.ItemNetworkFeeBinding import one.mixin.android.extension.getParcelableArrayListCompat import one.mixin.android.extension.loadImage +import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.MixinBottomSheetDialogFragment import one.mixin.android.util.viewBinding @@ -55,9 +56,9 @@ class NetworkFeeBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { binding.apply { rightIv.setOnClickListener { dismiss() } val currentFee = requireArguments().getString(ARGS_CURRENT_FEE) - val feeAdapter = FeeAdapter(currentFee ?: fees.first().token.assetId) + val feeAdapter = FeeAdapter(currentFee ?: fees.first().selectionKey) feeAdapter.callback = { networkFee -> - feeAdapter.currentFee = networkFee.token.assetId + feeAdapter.currentFee = networkFee.selectionKey this@NetworkFeeBottomSheetDialogFragment.callback?.invoke(networkFee) } feeRv.adapter = feeAdapter @@ -107,14 +108,14 @@ class NetworkFeeBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { ) { binding.apply { nameTv.text = networkFee.token.name - feeTv.text = "${networkFee.fee} ${networkFee.token.symbol}" + feeTv.text = "${networkFee.fee.numberFormat8()} ${networkFee.token.symbol}" assetIcon.apply { bg.loadImage(networkFee.token.iconUrl, R.drawable.ic_avatar_place_holder) badge.loadImage(networkFee.token.chainIconUrl, R.drawable.ic_avatar_place_holder) } - checkIv.isVisible = currentFee == networkFee.token.assetId + checkIv.isVisible = currentFee == networkFee.selectionKey root.setOnClickListener { - if (currentFee == networkFee.token.assetId) return@setOnClickListener + if (currentFee == networkFee.selectionKey) return@setOnClickListener callback?.invoke(networkFee) } @@ -127,7 +128,17 @@ class NetworkFeeBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { data class NetworkFee( val token: TokenItem, val fee: String, + val source: Source = Source.DEFAULT, ) : Parcelable { + val selectionKey: String + get() = "${source.name}:${token.assetId}" + + enum class Source { + DEFAULT, + LEGACY_WEB3, + GASLESS, + } + companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { @@ -135,7 +146,7 @@ data class NetworkFee( oldItem: NetworkFee, newItem: NetworkFee, ) = - oldItem.token.assetId == newItem.token.assetId + oldItem.selectionKey == newItem.selectionKey override fun areContentsTheSame( oldItem: NetworkFee, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/SwapTransferBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/SwapTransferBottomSheetDialogFragment.kt index db0f5437ad..efb5625621 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/SwapTransferBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/SwapTransferBottomSheetDialogFragment.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.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -67,7 +68,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.GaslessTxRequest +import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.request.web3.Web3RawTransactionRequest +import one.mixin.android.api.response.web3.EthGaslessTxPayload +import one.mixin.android.api.response.web3.GaslessTxResponse import one.mixin.android.api.response.web3.SwapResponse import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.api.response.web3.WalletOutput @@ -80,13 +85,15 @@ import one.mixin.android.extension.base64Encode import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.composeDp import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.hexStringToByteArray import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.hexStringToByteArray 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.toHex import one.mixin.android.extension.updatePinCheck import one.mixin.android.extension.withArgs @@ -115,8 +122,8 @@ 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 import one.mixin.android.util.getMixinErrorStringByCode import one.mixin.android.util.reportException @@ -125,9 +132,12 @@ import one.mixin.android.vo.User import one.mixin.android.vo.membershipIcon import one.mixin.android.vo.safe.TokenItem import one.mixin.android.vo.toUser +import one.mixin.android.widget.components.MixinButton 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 @@ -153,6 +163,7 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm companion object { const val TAG = "SwapTransferBottomSheetDialogFragment" + private const val GASLESS_EIP7702_AUTHORIZED_ADDRESS = "0xe6cae83bde06e4c305530e199d7217f42808555b" private const val ARGS_LINK = "args_link" private const val ARGS_ADDRESS = "args_address" private const val ARGS_IN_AMOUNT = "args_in_amount" @@ -277,10 +288,6 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm BigDecimal(requireNotNull(requireArguments().getString(ARGS_OUT_AMOUNT))) } - private val self by lazy { - requireNotNull(Session.getAccount()).toUser() - } - private val link by lazy { requireNotNull(requireArguments().getString(ARGS_LINK)).toUri() } @@ -298,6 +305,8 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm lateinit var tokenRepository: TokenRepository private var web3Transaction: JsSignMessage? by mutableStateOf(null) + private var gaslessPrepareResponse: GaslessTxResponse? by mutableStateOf(null) + private var isGaslessLoading by mutableStateOf(false) private var tipGas: TipGas? by mutableStateOf(null) private var solanaFee: BigDecimal? by mutableStateOf(null) private var btcFee: BigDecimal? by mutableStateOf(null) @@ -474,6 +483,17 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ItemPriceContent(title = stringResource(id = R.string.Price).uppercase(), inAmount = inAmount, inAsset = inAsset, outAmount = outAmount, outAsset = outAsset) Box(modifier = Modifier.height(20.dp)) if (source == "web3") { + val chainId = token?.chainId ?: inAsset.chain.chainId + val isGaslessPrepared = web3Transaction?.type == JsSignMessage.TYPE_GASLESS_TRANSFER && gaslessPrepareResponse != null + val shouldShowFeeLoading = + !isGaslessPrepared && ( + isGaslessLoading || + (chainId == Constants.ChainId.SOLANA_CHAIN_ID && solanaFee == null) || + (chainId == Constants.ChainId.BITCOIN_CHAIN_ID && btcFee == null) || + (chainId != Constants.ChainId.SOLANA_CHAIN_ID && + chainId != Constants.ChainId.BITCOIN_CHAIN_ID && + tipGas == null) + ) if (tipGas != null || solanaFee != null || btcFee != null) { val transaction = web3Transaction?.wcEthereumTransaction val fee = btcFee?.stripTrailingZeros() ?: solanaFee?.stripTrailingZeros() ?: tipGas?.displayValue(transaction?.maxFeePerGas) ?: BigDecimal.ZERO @@ -490,7 +510,7 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ) } } else { - FeeInfo("0", BigDecimal("0")) + FeeInfo("0", BigDecimal("0"), isLoading = shouldShowFeeLoading) } } else { FeeInfo("0", BigDecimal("0")) @@ -618,9 +638,22 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm lifecycleScope.launch(Dispatchers.IO) { try { step = Step.Sending - if (source == "web3" && web3Transaction != null) { + if (source == "web3") { + val signMessage = requireNotNull(web3Transaction) { "Transaction not ready" } try { - when (web3Transaction!!.type) { + when (signMessage.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" }) { @@ -642,7 +675,7 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } JsSignMessage.TYPE_TRANSACTION -> { - val transaction = requireNotNull(web3Transaction!!.wcEthereumTransaction) + val transaction = requireNotNull(signMessage.wcEthereumTransaction) val priv = bottomViewModel.getWeb3Priv(requireContext(), pin, inAsset.chain.chainId) val pair = Web3Signer.ethSignTransaction(priv, transaction, tipGas!!, chain = token?.getChainFromName()) { address -> val nonce = rpc.nonceAt(inAsset.chain.chainId, address) ?: throw IllegalArgumentException("failed to get nonce") @@ -660,13 +693,13 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } JsSignMessage.TYPE_BTC_TRANSACTION -> { - val rawHex: String = web3Transaction!!.data ?: throw IllegalArgumentException("empty btc transaction hex") + val rawHex: String = signMessage.data ?: throw IllegalArgumentException("empty btc transaction hex") val priv: ByteArray = bottomViewModel.getWeb3Priv(requireContext(), pin, Constants.ChainId.BITCOIN_CHAIN_ID) val key: ECKey = ECKey.fromPrivate(priv, true) val fromAddress: String = key.toAddress(ScriptType.P2WPKH, BitcoinNetwork.MAINNET).toString() val localUtxos: List = web3ViewModel.outputsByAddress(fromAddress, Constants.ChainId.BITCOIN_CHAIN_ID) val signedResult: BtcSignedResult = signBtcTransaction(rawHex, key, localUtxos) - bottomViewModel.postRawTx(signedResult.signedHex, Constants.ChainId.BITCOIN_CHAIN_ID, fromAddress, inAsset.assetId, rate = web3Transaction?.rate) + bottomViewModel.postRawTx(signedResult.signedHex, Constants.ChainId.BITCOIN_CHAIN_ID, fromAddress, inAsset.assetId, rate = signMessage.rate) web3ViewModel.markOutputsToSigned(Web3Signer.currentWalletId, fromAddress, signedResult.signedHex, signedResult.consumedOutputIds) defaultSharedPreferences.putLong( Constants.BIOMETRIC_PIN_CHECK, @@ -679,7 +712,7 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } else -> { - throw IllegalArgumentException("invalid signMessage type ${web3Transaction!!.type}") + throw IllegalArgumentException("invalid signMessage type ${signMessage.type}") } } } catch (e: Exception) { @@ -818,12 +851,34 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Web3Signer.evmAddress } senderAddress = fromAddress + this@SwapTransferBottomSheetDialogFragment.token = token val localUtxos: List? = if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { web3ViewModel.outputsByAddress(fromAddress, Constants.ChainId.BITCOIN_CHAIN_ID) } else { null } val inputAmount: String = requireArguments().getString(ARGS_IN_AMOUNT)!! + if (token.chainId != Constants.ChainId.BITCOIN_CHAIN_ID) { + isGaslessLoading = true + val gaslessPayload = try { + probeGaslessAvailability( + token = token, + fromAddress = fromAddress, + toAddress = depositDestination, + amount = inputAmount, + ) + } finally { + isGaslessLoading = false + } + if (gaslessPayload != null) { + gaslessPrepareResponse = gaslessPayload + web3Transaction = JsSignMessage( + callbackId = 0L, + type = JsSignMessage.TYPE_GASLESS_TRANSFER, + ) + return@let + } + } val transaction: JsSignMessage = if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { val zeroFeeTx: JsSignMessage = token.buildTransaction( rpc = rpc, @@ -853,7 +908,6 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ) } web3Transaction = transaction - this@SwapTransferBottomSheetDialogFragment.token = token val chain = token.getChainFromName() refreshEstimatedGasAndAsset(chain) @@ -966,6 +1020,176 @@ class SwapTransferBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm }.launchIn(lifecycleScope) } + private suspend fun probeGaslessAvailability( + token: Web3TokenItem, + fromAddress: String, + toAddress: String, + amount: String, + ): GaslessTxResponse? { + return runCatching { + web3ViewModel.gaslessPrepare( + GaslessTxRequest( + from = fromAddress, + to = toAddress, + assetId = token.assetId, + amount = amount, + feeAssetId = token.assetId, + chainId = token.chainId, + ), + ) + }.getOrNull()?.data + } + + private suspend fun prepareGaslessTransfer( + token: Web3TokenItem, + fromAddress: String, + toAddress: String, + amount: String, + ): GaslessTxResponse { + gaslessPrepareResponse?.let { return it } + val response = web3ViewModel.gaslessPrepare( + GaslessTxRequest( + from = fromAddress, + to = toAddress, + assetId = token.assetId, + amount = amount, + feeAssetId = token.assetId, + chainId = token.chainId, + ), + ) + if (!response.isSuccess || response.data == null) { + throw IllegalStateException(response.errorDescription) + } + return response.data!! + } + + private suspend fun submitGaslessTransfer(pin: String) { + val transferToken = requireNotNull(token) { "required web3 token can not be null" } + val fromAddress = requireNotNull(senderAddress) { "sender address can not be null" } + val toAddress = requireNotNull(depositDestination) { "deposit destination can not be null" } + val amount = inAmount.stripTrailingZeros().toPlainString() + val preparedResponse = prepareGaslessTransfer(transferToken, fromAddress, toAddress, amount) + 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?, @@ -1206,6 +1430,8 @@ fun AssetChanges( text = outAsset.chain.name, color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, + textAlign = TextAlign.End, + maxLines = 1 ) } } diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt index dd77d5bcd8..6e61581d15 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt @@ -29,6 +29,7 @@ import one.mixin.android.db.web3.vo.Web3RawTransaction import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.Web3TransactionItem import one.mixin.android.db.web3.vo.Web3Wallet +import one.mixin.android.db.web3.vo.isGaslessPending import one.mixin.android.extension.buildAmountSymbol import one.mixin.android.extension.colorFromAttribute import one.mixin.android.extension.forEachWithIndex @@ -536,7 +537,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) avatar.badge.isVisible = false dateTv.text = transaction.transactionAt.fullDate() - feeLl.isVisible = transaction.transactionType != TransactionType.TRANSFER_IN.value && transaction.fee.isNotEmpty() + feeLl.isVisible = shouldShowFee(transaction.status) feeTv.text = "${transaction.fee} ${transaction.chainSymbol ?: ""}" statusLl.isVisible = false @@ -581,10 +582,12 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } else { assetChangesLl.visibility = View.GONE } - if (transaction.status == TransactionStatus.PENDING.value - && transaction.chainId != Constants.ChainId.SOLANA_CHAIN_ID) { + if (transaction.status == TransactionStatus.PENDING.value) { lifecycleScope.launch { - updateActions() + updateFeeVisibility(transaction.status) + if (transaction.chainId != Constants.ChainId.SOLANA_CHAIN_ID) { + updateActions() + } } } } @@ -608,9 +611,37 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) return !isOnlyOutputToSelf } - private fun updateActions() { + private fun shouldShowFee( + status: String, + pendingRawTx: Web3RawTransaction? = null, + ): Boolean { + if (transaction.transactionType == TransactionType.TRANSFER_IN.value || transaction.fee.isEmpty()) { + return false + } + if (status != TransactionStatus.PENDING.value) { + return true + } + return pendingRawTx?.isGaslessPending() == false + } + + private suspend fun updateFeeVisibility(status: String = transaction.status) { + val pendingRawTx: Web3RawTransaction? = if (status == TransactionStatus.PENDING.value) { + web3ViewModel.getRawTransactionByHashAndChain(wallet.id, transaction.transactionHash, transaction.chainId) + } else { + null + } + binding.feeLl.isVisible = shouldShowFee(status, pendingRawTx) + } + + private fun updateActions(status: String = transaction.status) { lifecycleScope.launch { binding.apply { + if (status != TransactionStatus.PENDING.value) { + actions.isVisible = false + actions.speedUp.setOnClickListener(null) + actions.cancelTx.setOnClickListener(null) + return@apply + } val pendingRawTx = web3ViewModel.getRawTransactionByHashAndChain(wallet.id, transaction.transactionHash, transaction.chainId) val shouldShowActions = pendingRawTx != null @@ -621,6 +652,12 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) return@apply } val notNullPendingRawTx: Web3RawTransaction = pendingRawTx + if (notNullPendingRawTx.isGaslessPending()) { + actions.isVisible = false + actions.speedUp.setOnClickListener(null) + actions.cancelTx.setOnClickListener(null) + return@apply + } if (token.chainId == Constants.ChainId.BITCOIN_CHAIN_ID) { val hasSignedChange: Boolean = web3ViewModel.hasBitcoinSignedOutputsByTransactionHash(transaction.transactionHash) if (hasSignedChange) { @@ -725,7 +762,11 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } } } - updateActions() + binding.feeLl.isVisible = shouldShowFee(newStatus) + lifecycleScope.launch { + updateFeeVisibility(newStatus) + } + updateActions(newStatus) } private fun handleSpeedUp(rawTransaction: Web3RawTransaction) { diff --git a/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt b/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt index 8afde9c3bc..425fe6ea82 100644 --- a/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt +++ b/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt @@ -32,6 +32,7 @@ class JsSignMessage( const val TYPE_SIGN_IN = 5 const val TYPE_BTC_TRANSACTION = 6 + const val TYPE_GASLESS_TRANSFER = 7 fun isSignMessage(type: Int): Boolean = type == TYPE_MESSAGE || type == TYPE_TYPED_MESSAGE || type == TYPE_PERSONAL_MESSAGE || type == TYPE_SIGN_IN @@ -51,6 +52,7 @@ class JsSignMessage( fun isBtcMessage() = type == TYPE_BTC_TRANSACTION fun isSolMessage() = type == TYPE_RAW_TRANSACTION || type == TYPE_SIGN_IN fun isEvmMessage() = type == TYPE_TYPED_MESSAGE || type == TYPE_PERSONAL_MESSAGE || type == TYPE_TRANSACTION + fun isGaslessTransfer() = type == TYPE_GASLESS_TRANSFER val reviewData: String? get() { diff --git a/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt b/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt index 37273fc476..e1fea94ae7 100644 --- a/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt +++ b/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt @@ -374,11 +374,14 @@ object Web3Signer { ): String { val keyPair = ECKeyPair.create(priv) val signature = - if (type == JsSignMessage.TYPE_TYPED_MESSAGE) { - val encoder = StructuredDataEncoder(message) - Sign.signMessage(encoder.hashStructuredData(), keyPair, false) - } else { - Sign.signPrefixedMessage(Numeric.hexStringToByteArray(message), keyPair) + when (type) { + JsSignMessage.TYPE_GASLESS_TRANSFER -> + Sign.signMessage(Numeric.hexStringToByteArray(message), keyPair, false) + JsSignMessage.TYPE_TYPED_MESSAGE -> { + val encoder = StructuredDataEncoder(message) + Sign.signMessage(encoder.hashStructuredData(), keyPair, false) + } + else -> Sign.signPrefixedMessage(Numeric.hexStringToByteArray(message), keyPair) } val b = ByteArray(65) System.arraycopy(signature.r, 0, b, 0, 32)