diff --git a/build.gradle.kts b/build.gradle.kts index e8d4c4cd7..89abfba13 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ import org.apache.http.impl.client.HttpClients import org.apache.http.util.EntityUtils import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.sonarqube.gradle.SonarExtension +import java.net.URL import java.util.Base64 import javax.xml.parsers.DocumentBuilderFactory @@ -108,8 +109,6 @@ task("clean") { delete(rootProject.layout.buildDirectory) } - - nexusPublishing { repositories { // project.version = "-SNAPSHOT" diff --git a/product/walletkit/build.gradle.kts b/product/walletkit/build.gradle.kts index 3268e47b6..3fdb3d1b1 100644 --- a/product/walletkit/build.gradle.kts +++ b/product/walletkit/build.gradle.kts @@ -1,3 +1,6 @@ +import com.android.build.api.dsl.Packaging +import java.net.URL + plugins { id("com.android.library") id(libs.plugins.kotlin.android.get().pluginId) @@ -33,6 +36,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "${rootDir.path}/gradle/proguard-rules/sdk-rules.pro", "${projectDir}/web3wallet-rules.pro") } } + lint { abortOnError = true ignoreWarnings = true @@ -43,6 +47,12 @@ android { sourceCompatibility = jvmVersion targetCompatibility = jvmVersion } + + packaging { + jniLibs.pickFirsts.add("lib/arm64-v8a/libuniffi_yttrium.so") + jniLibs.pickFirsts.add("lib/armeabi-v7a/libuniffi_yttrium.so") + } + kotlinOptions { jvmTarget = jvmVersion.toString() freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.time.ExperimentalTime" @@ -54,6 +64,9 @@ android { } dependencies { + implementation("net.java.dev.jna:jna:5.15.0@aar") + implementation("com.github.reown-com:yttrium:0.4.11") + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) @@ -62,4 +75,19 @@ dependencies { releaseImplementation("com.reown:android-core:$CORE_VERSION") releaseImplementation("com.reown:sign:$SIGN_VERSION") + + testImplementation(libs.bundles.androidxTest) + testImplementation(libs.robolectric) + testImplementation(libs.json) + testImplementation(libs.coroutines.test) + testImplementation(libs.bundles.scarlet.test) + testImplementation(libs.bundles.sqlDelight.test) + testImplementation(libs.koin.test) + + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.coroutines.test) + androidTestImplementation(libs.core) + + androidTestUtil(libs.androidx.testOrchestrator) + androidTestImplementation(libs.bundles.androidxAndroidTest) } \ No newline at end of file diff --git a/product/walletkit/lint.xml b/product/walletkit/lint.xml new file mode 100644 index 000000000..a346eb3cd --- /dev/null +++ b/product/walletkit/lint.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/ChainAbstractionStatusUseCaseTest.kt b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/ChainAbstractionStatusUseCaseTest.kt new file mode 100644 index 000000000..1aa775a28 --- /dev/null +++ b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/ChainAbstractionStatusUseCaseTest.kt @@ -0,0 +1,100 @@ +package com.reown.walletkit + +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.use_cases.ChainAbstractionStatusUseCase +import io.mockk.coEvery +import io.mockk.mockk +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.StatusResponse +import uniffi.yttrium.StatusResponseCompleted +import uniffi.yttrium.StatusResponseError +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@ExperimentalCoroutinesApi +class ChainAbstractionStatusUseCaseTest { + private val chainAbstractionClient: ChainAbstractionClient = mockk() + private val chainAbstractionStatusUseCase = ChainAbstractionStatusUseCase(chainAbstractionClient) + + @Test + fun shouldCallOnSuccessWhenStatusIsCompleted() = runTest { + val fulfilmentId = "123" + val checkIn = 1000L + val completedResult = StatusResponse.Completed(StatusResponseCompleted(createdAt = 1u)) + + coEvery { chainAbstractionClient.status(fulfilmentId) } returns completedResult + + val result = async { + suspendCoroutine { continuation -> + chainAbstractionStatusUseCase.invoke( + fulfilmentId, + checkIn, + onSuccess = { + continuation.resume(true) + }, + onError = { + continuation.resume(false) + } + ) + } + }.await() + + assertTrue(result) + } + + @Test + fun shouldCallOnErrorWhenStatusIsError() = runTest { + val fulfilmentId = "123" + val checkIn = 1000L + val errorResult = StatusResponse.Error(StatusResponseError(createdAt = 1u, error = "error")) + + coEvery { chainAbstractionClient.status(fulfilmentId) } returns errorResult + + val result = async { + suspendCoroutine { continuation -> + chainAbstractionStatusUseCase.invoke( + fulfilmentId, + checkIn, + onSuccess = { + continuation.resume(true) + }, + onError = { + continuation.resume(it) + } + ) + } + }.await() + + assertTrue(result is Wallet.Model.Status.Error) + } + + @Test + fun shouldCallOnErrorWhenErrorIsThrown() = runTest { + val fulfilmentId = "123" + val checkIn = 1000L + + coEvery { chainAbstractionClient.status(fulfilmentId) } throws RuntimeException("error") + + val result = async { + suspendCoroutine { continuation -> + chainAbstractionStatusUseCase.invoke( + fulfilmentId, + checkIn, + onSuccess = { + continuation.resume(false) + }, + onError = { + continuation.resume(true) + } + ) + } + }.await() + + assertTrue(result) + } +} \ No newline at end of file diff --git a/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/GetTransactionDetailsUseCaseTest.kt b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/GetTransactionDetailsUseCaseTest.kt new file mode 100644 index 000000000..a4f7be69d --- /dev/null +++ b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/GetTransactionDetailsUseCaseTest.kt @@ -0,0 +1,127 @@ +package com.reown.walletkit + +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.toWallet +import com.reown.walletkit.use_cases.GetTransactionDetailsUseCase +import io.mockk.coEvery +import io.mockk.mockk +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.Amount +import uniffi.yttrium.FeeEstimatedTransaction +import uniffi.yttrium.Transaction +import uniffi.yttrium.TransactionFee +import uniffi.yttrium.TxnDetails +import uniffi.yttrium.UiFields +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@ExperimentalCoroutinesApi +class GetTransactionDetailsUseCaseTest { + private val chainAbstractionClient: ChainAbstractionClient = mockk() + private val getTransactionDetailsUseCase = GetTransactionDetailsUseCase(chainAbstractionClient) + + @Test + fun shouldCallOnSuccessWithExpectedResultWhenClientSucceeds() = runTest { + val available = Wallet.Model.PrepareSuccess.Available( + fulfilmentId = "123", + checkIn = 11, + initialTransaction = transaction.toWallet(), + transactions = listOf(transaction.toWallet()), + funding = listOf(Wallet.Model.FundingMetadata(chainId = "1", tokenContract = "token", symbol = "s", amount = "11", decimals = 18, bridgingFee = "0")), + initialTransactionMetadata = Wallet.Model.InitialTransactionMetadata(transferTo = "aa", amount = "11", tokenContract = "cc", symbol = "s", decimals = 18) + ) + val resultFields = UiFields( + route = listOf(txDetails), + initial = txDetails, + bridge = listOf(TransactionFee(Amount("11", "18", 2u, "22", "1222"), Amount("11", "18", 2u, "22", "1222"))), + localTotal = Amount("11", "18", 2u, "22", "1222"), + localBridgeTotal = Amount("11", "18", 2u, "22", "1222"), + localRouteTotal = Amount("11", "18", 2u, "22", "1222"), + ) + + coEvery { chainAbstractionClient.getUiFields(any(), any()) } returns resultFields + + val result = async { + suspendCoroutine { continuation -> + getTransactionDetailsUseCase.invoke( + available, + onSuccess = { + continuation.resume(true) + }, + onError = { + continuation.resume(false) + } + ) + } + }.await() + + assertTrue(result) + } + + @Test + fun shouldCallOnErrorWhenClientThrowsAnException() = runTest { + val available = Wallet.Model.PrepareSuccess.Available( + fulfilmentId = "123", + checkIn = 11, + initialTransaction = transaction.toWallet(), + transactions = listOf(transaction.toWallet()), + funding = listOf(Wallet.Model.FundingMetadata(chainId = "1", tokenContract = "token", symbol = "s", amount = "11", decimals = 18, bridgingFee = "0")), + initialTransactionMetadata = Wallet.Model.InitialTransactionMetadata(transferTo = "aa", amount = "11", tokenContract = "cc", symbol = "s", decimals = 18) + ) + val exception = Exception("Some error occurred") + + coEvery { chainAbstractionClient.getUiFields(any(), any()) } throws exception + + val result = async { + suspendCoroutine { continuation -> + getTransactionDetailsUseCase.invoke( + available, + onSuccess = { + println("success: $it") + continuation.resume(false) + }, + onError = { + println("test1 error: $it") + continuation.resume(true) + } + ) + } + }.await() + + assertTrue(result) + } + + companion object { + val transaction = Transaction( + from = "from", + to = "to", + value = "value", + input = "data", + nonce = "nonce", + gasLimit = "gas", + chainId = "1" + ) + + private val feeEstimatedTransactionMetadata = FeeEstimatedTransaction( + from = "from", + to = "to", + value = "value", + input = "data", + nonce = "nonce", + gasLimit = "gas", + chainId = "1", + maxPriorityFeePerGas = "11", + maxFeePerGas = "33" + ) + + val txDetails = TxnDetails( + transaction = feeEstimatedTransactionMetadata, + fee = TransactionFee(Amount("11", "18", 2u, "22", "1222"), Amount("11", "18", 2u, "22", "1222")), + ) + } +} \ No newline at end of file diff --git a/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/PrepareFulfilmentUseCaseTests.kt b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/PrepareFulfilmentUseCaseTests.kt new file mode 100644 index 000000000..7fe8f106c --- /dev/null +++ b/product/walletkit/src/androidTest/kotlin/com/reown/walletkit/PrepareFulfilmentUseCaseTests.kt @@ -0,0 +1,172 @@ +package com.reown.walletkit + +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.use_cases.PrepareChainAbstractionUseCase +import io.mockk.coEvery +import io.mockk.mockk +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.BridgingError +import uniffi.yttrium.FundingMetadata +import uniffi.yttrium.InitialTransactionMetadata +import uniffi.yttrium.Metadata +import uniffi.yttrium.PrepareResponse +import uniffi.yttrium.RouteResponseAvailable +import uniffi.yttrium.RouteResponseError +import uniffi.yttrium.RouteResponseNotRequired +import uniffi.yttrium.RouteResponseSuccess +import uniffi.yttrium.Transaction +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class PrepareChainAbstractionUseCaseTest { + private val chainAbstractionClient: ChainAbstractionClient = mockk() + private val prepareChainAbstractionUseCase = PrepareChainAbstractionUseCase(chainAbstractionClient) + + @Test + fun shouldCallOnSuccessWithAvailableResult() = runTest { + val successResult = PrepareResponse.Success( + RouteResponseSuccess.Available( + RouteResponseAvailable( + orchestrationId = "123", + initialTransaction = transaction, + metadata = Metadata( + fundingFrom = listOf(FundingMetadata(chainId = "1", tokenContract = "token", symbol = "s", amount = "11", decimals = 18u, bridgingFee = "0")), + initialTransaction = InitialTransactionMetadata(transferTo = "aa", amount = "11", tokenContract = "cc", symbol = "s", decimals = 18u), + checkIn = 11u + ), + transactions = listOf(transaction) + ) + ) + ) + coEvery { chainAbstractionClient.prepare(any()) } returns successResult + + val result = async { + suspendCoroutine { continuation -> + prepareChainAbstractionUseCase.invoke( + initTransaction, + onSuccess = { + continuation.resume(true) + }, + onError = { + continuation.resume(false) + } + ) + } + }.await() + + assertTrue(result) + } + + @Test + fun shouldCallOnSuccessWithNotRequiredResult() = runTest { + val successResult = PrepareResponse.Success(RouteResponseSuccess.NotRequired(RouteResponseNotRequired(initialTransaction = transaction, transactions = emptyList()))) + coEvery { chainAbstractionClient.prepare(any()) } returns successResult + + val result = async { + suspendCoroutine { continuation -> + prepareChainAbstractionUseCase.invoke( + initTransaction, + onSuccess = { + continuation.resume(it) + }, + onError = { + continuation.resume(false) + } + ) + } + }.await() + + assertTrue(result is Wallet.Model.PrepareSuccess.NotRequired) + } + + @Test + fun shouldCallOnErrorWithNoRoutesAvailableError() = runTest { + val errorResult = PrepareResponse.Error(RouteResponseError(BridgingError.NO_ROUTES_AVAILABLE)) + + coEvery { chainAbstractionClient.prepare(any()) } returns errorResult + + val result = async { + suspendCoroutine { continuation -> + prepareChainAbstractionUseCase.invoke( + initTransaction, + onSuccess = { + continuation.resume(false) + }, + onError = { + continuation.resume(it) + } + ) + } + }.await() + + assertTrue(result is Wallet.Model.PrepareError.NoRoutesAvailable) + } + + @Test + fun shouldCallOnErrorWithInsufficientFundsError() = runTest { + val errorResult = PrepareResponse.Error(RouteResponseError(BridgingError.INSUFFICIENT_FUNDS)) + + coEvery { chainAbstractionClient.prepare(any()) } returns errorResult + + val result = async { + suspendCoroutine { continuation -> + prepareChainAbstractionUseCase.invoke( + initTransaction, + onSuccess = { + continuation.resume(false) + }, + onError = { + continuation.resume(it) + } + ) + } + }.await() + + assertTrue(result is Wallet.Model.PrepareError.InsufficientFunds) + } + + @Test + fun shouldCallOnErrorWithUnknownErrorOnException() = runTest { + coEvery { chainAbstractionClient.prepare(any()) } throws RuntimeException("Some unexpected error") + + val result = async { + suspendCoroutine { continuation -> + prepareChainAbstractionUseCase.invoke( + initTransaction, + onSuccess = { + continuation.resume(false) + }, + onError = { + continuation.resume(true) + } + ) + } + }.await() + + assertTrue(result) + } + + companion object { + val transaction = Transaction( + from = "from", + to = "to", + value = "value", + input = "data", + nonce = "nonce", + chainId = "1", + gasLimit = "0" + ) + + val initTransaction = Wallet.Model.InitialTransaction( + from = "from", + to = "to", + value = "value", + chainId = "1", + input = "data" + ) + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Annotations.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Annotations.kt new file mode 100644 index 000000000..bccbb2892 --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Annotations.kt @@ -0,0 +1,17 @@ +package com.reown.walletkit.client + +@RequiresOptIn( + message = "This API is experimental and may change in a future release.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +annotation class ChainAbstractionExperimentalApi + +@RequiresOptIn( + message = "This API is experimental and may change in a future release.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +annotation class SmartAccountExperimentalApi \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/ClientMapper.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/ClientMapper.kt index 2946b1ee2..8905591e9 100644 --- a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/ClientMapper.kt +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/ClientMapper.kt @@ -2,6 +2,21 @@ package com.reown.walletkit.client import com.reown.android.internal.common.signing.cacao.CacaoType import com.reown.sign.client.Sign +import uniffi.uniffi_yttrium.Eip1559Estimation +import uniffi.uniffi_yttrium.OwnerSignature +import uniffi.uniffi_yttrium.PreparedSendTransaction +import uniffi.uniffi_yttrium.FfiTransaction +import uniffi.yttrium.Amount +import uniffi.yttrium.FeeEstimatedTransaction +import uniffi.yttrium.FundingMetadata +import uniffi.yttrium.InitialTransaction +import uniffi.yttrium.InitialTransactionMetadata +import uniffi.yttrium.Metadata as YMetadata +import uniffi.yttrium.Transaction +import uniffi.yttrium.RouteResponseAvailable +import uniffi.yttrium.TransactionFee +import uniffi.yttrium.TxnDetails +import uniffi.yttrium.UiFields @JvmSynthetic internal fun Map.toSign(): Map = @@ -277,6 +292,151 @@ internal fun Sign.Model.Cacao.toWallet(): Wallet.Model.Cacao = with(this) { @JvmSynthetic internal fun Sign.Model.ConnectionState.Reason.toWallet(): Wallet.Model.ConnectionState.Reason = when (this) { - is Sign.Model.ConnectionState.Reason.ConnectionClosed -> Wallet.Model.ConnectionState.Reason.ConnectionClosed(this.message) - is Sign.Model.ConnectionState.Reason.ConnectionFailed -> Wallet.Model.ConnectionState.Reason.ConnectionFailed(this.throwable) -} \ No newline at end of file + is Sign.Model.ConnectionState.Reason.ConnectionClosed -> Wallet.Model.ConnectionState.Reason.ConnectionClosed(this.message) + is Sign.Model.ConnectionState.Reason.ConnectionFailed -> Wallet.Model.ConnectionState.Reason.ConnectionFailed(this.throwable) +} + +@JvmSynthetic +internal fun PreparedSendTransaction.toWallet(): Wallet.Params.PrepareSendTransactionsResult = Wallet.Params.PrepareSendTransactionsResult(hash, doSendTransactionParams) + +@JvmSynthetic +internal fun Wallet.Params.Transaction.toYttrium(): FfiTransaction = FfiTransaction(to = to, value = value, data = data) + +@JvmSynthetic +internal fun Wallet.Params.OwnerSignature.toYttrium(): OwnerSignature = OwnerSignature(owner = address, signature = signature) + +@JvmSynthetic +internal fun RouteResponseAvailable.toWallet(): Wallet.Model.PrepareSuccess.Available = + Wallet.Model.PrepareSuccess.Available( + fulfilmentId = orchestrationId, + checkIn = metadata.checkIn.toLong(), + transactions = transactions.map { it.toWallet() }, + initialTransaction = initialTransaction.toWallet(), + initialTransactionMetadata = metadata.initialTransaction.toWallet(), + funding = metadata.fundingFrom.map { it.toWallet() } + ) + +@JvmSynthetic +internal fun Wallet.Model.PrepareSuccess.Available.toYttrium(): RouteResponseAvailable = + RouteResponseAvailable( + fulfilmentId, + metadata = YMetadata( + checkIn = checkIn.toULong(), + initialTransaction = initialTransactionMetadata.toYttrium(), + fundingFrom = funding.map { it.toYttrium() } + ), + initialTransaction = initialTransaction.toYttrium(), + transactions = transactions.map { it.toYttrium() }) + +@JvmSynthetic +private fun Wallet.Model.InitialTransactionMetadata.toYttrium(): InitialTransactionMetadata = + InitialTransactionMetadata( + transferTo = transferTo, + symbol = symbol, + amount = amount, + tokenContract = tokenContract, + decimals = decimals.toUByte() + ) + +@JvmSynthetic +private fun InitialTransactionMetadata.toWallet(): Wallet.Model.InitialTransactionMetadata = + Wallet.Model.InitialTransactionMetadata( + transferTo = transferTo, + symbol = symbol, + amount = amount, + tokenContract = tokenContract, + decimals = decimals.toInt() + ) + +@JvmSynthetic +fun Wallet.Model.InitialTransaction.toInitialYttrium(): InitialTransaction = InitialTransaction( + from = from, + to = to, + value = value, + chainId = chainId, + input = input, +) + +@JvmSynthetic +fun Transaction.toWallet(): Wallet.Model.Transaction = Wallet.Model.Transaction( + from = from, + to = to, + value = value, + gasLimit = gasLimit, + input = input, + nonce = nonce, + chainId = chainId +) + +@JvmSynthetic +fun Wallet.Model.Transaction.toYttrium(): Transaction = Transaction( + from = from, + to = to, + value = value, + gasLimit = gasLimit, + input = input, + nonce = nonce, + chainId = chainId +) + +@JvmSynthetic +private fun Wallet.Model.FundingMetadata.toYttrium(): FundingMetadata = FundingMetadata(chainId, tokenContract, symbol, amount = amount, bridgingFee = bridgingFee, decimals = decimals.toUByte()) + +@JvmSynthetic +private fun FundingMetadata.toWallet(): Wallet.Model.FundingMetadata = Wallet.Model.FundingMetadata(chainId, tokenContract, symbol, amount, bridgingFee, 1) + +@JvmSynthetic +internal fun Eip1559Estimation.toWallet(): Wallet.Model.EstimatedFees = Wallet.Model.EstimatedFees(maxFeePerGas = maxFeePerGas, maxPriorityFeePerGas = maxPriorityFeePerGas) + +@JvmSynthetic +internal fun UiFields.toWallet(): Wallet.Model.TransactionsDetails = Wallet.Model.TransactionsDetails( + localTotal = localTotal.toWallet(), + initialDetails = initial.toWallet(), + fulfilmentDetails = route.map { it.toWallet() }, + bridgeFees = bridge.map { it.toWallet() }, + localFulfilmentTotal = localRouteTotal.toWallet(), + localBridgeTotal = localBridgeTotal.toWallet() +) + +@JvmSynthetic +internal fun Amount.toWallet(): Wallet.Model.Amount = Wallet.Model.Amount( + symbol = symbol, + amount = amount, + unit = unit.toString(), + formattedAlt = formattedAlt, + formatted = formatted +) + +private fun TxnDetails.toWallet(): Wallet.Model.TransactionDetails = Wallet.Model.TransactionDetails( + transaction = transaction.toWallet(), + transactionFee = fee.toWallet() +) + +fun FeeEstimatedTransaction.toWallet(): Wallet.Model.FeeEstimatedTransaction = Wallet.Model.FeeEstimatedTransaction( + from = from, + to = to, + value = value, + gasLimit = gasLimit, + input = input, + nonce = nonce, + maxFeePerGas = maxFeePerGas, + maxPriorityFeePerGas = maxPriorityFeePerGas, + chainId = chainId +) + +private fun TransactionFee.toWallet() = Wallet.Model.TransactionFee( + fee = Wallet.Model.Amount( + symbol = fee.symbol, + amount = fee.amount, + unit = fee.unit.toString(), + formattedAlt = fee.formattedAlt, + formatted = fee.formatted + ), + localFee = Wallet.Model.Amount( + symbol = localFee.symbol, + amount = localFee.amount, + unit = localFee.unit.toString(), + formattedAlt = localFee.formattedAlt, + formatted = localFee.formatted + ) +) \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Wallet.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Wallet.kt index bf6ad5217..93838e684 100644 --- a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Wallet.kt +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/Wallet.kt @@ -18,13 +18,18 @@ object Wallet { } sealed class Params { - data class Init(val core: CoreInterface) : Params() + data class Init( + val core: CoreInterface, + @SmartAccountExperimentalApi + val pimlicoApiKey: String? = null + ) : Params() data class Pair(val uri: String) : Params() data class SessionApprove( val proposerPublicKey: String, val namespaces: Map, + val properties: Map? = null, val relayProtocol: String? = null, ) : Params() @@ -58,6 +63,15 @@ object Wallet { } data class DecryptMessage(val topic: String, val encryptedMessage: String) : Params() + data class GetSmartAccountAddress(val owner: Account) : Params() + data class PrepareSendTransactions(val transactions: List, val owner: Account) : Params() + data class DoSendTransactions(val owner: Account, val signatures: List, val doSendTransactionParams: String) : Params() + data class PrepareSendTransactionsResult(var hash: String, var doSendTransactionParams: String) : Params() + data class DoSendTransactionsResult(var userOperationHash: String) : Params() + data class WaitForUserOperationReceipt(var owner: Account, var userOperationHash: String) : Params() + data class OwnerSignature(val address: String, val signature: String) : Params() + data class Account(val address: String) : Params() + data class Transaction(val to: String, val value: String, val data: String) : Params() } sealed class Model { @@ -69,6 +83,123 @@ object Wallet { data class Error(val throwable: Throwable) : Model() + data class Transaction( + var from: String, + var to: String, + var value: String, + var gasLimit: String, + var input: String, + var nonce: String, + var chainId: String + ) : Model() + + data class InitialTransaction( + var from: String, + var to: String, + var value: String, + var input: String, + var chainId: String + ) : Model() + + data class FeeEstimatedTransaction( + var from: String, + var to: String, + var value: String, + var gasLimit: String, + var input: String, + var nonce: String, + var maxFeePerGas: String, + var maxPriorityFeePerGas: String, + var chainId: String + ) : Model() + + data class FundingMetadata( + var chainId: String, + var tokenContract: String, + var symbol: String, + var amount: String, + var bridgingFee: String, + var decimals: Int + ) : Model() + + data class InitialTransactionMetadata( + var symbol: String, + var amount: String, + var decimals: Int, + var tokenContract: String, + var transferTo: String + ) : Model() + + + data class EstimatedFees( + val maxFeePerGas: String, + val maxPriorityFeePerGas: String + ) : Model() + + sealed class PrepareSuccess : Model() { + data class Available( + val fulfilmentId: String, + val checkIn: Long, + val transactions: List, + val initialTransaction: Transaction, + val initialTransactionMetadata: InitialTransactionMetadata, + val funding: List + ) : PrepareSuccess() + + data class NotRequired(val initialTransaction: Transaction) : PrepareSuccess() + } + +// enum class Currency { +// USD, +// EUR, +// GBP, +// AUD, +// CAD, +// INR, +// JPY, +// BTC, +// ETH; +// } + + data class Amount( + var symbol: String, + var amount: String, + var unit: String, + var formatted: String, + var formattedAlt: String + ) : Model() + + data class TransactionFee( + var fee: Amount, + var localFee: Amount + ) : Model() + + data class TransactionDetails( + var transaction: FeeEstimatedTransaction, + var transactionFee: TransactionFee + ) : Model() + + data class TransactionsDetails( + var fulfilmentDetails: List, + var initialDetails: TransactionDetails, + var bridgeFees: List, + var localBridgeTotal: Amount, + var localFulfilmentTotal: Amount, + var localTotal: Amount + ) : Model() + + sealed class PrepareError : Model() { + data object NoRoutesAvailable : PrepareError() + data object InsufficientFunds : PrepareError() + data object InsufficientGasFunds : PrepareError() + data class Unknown(val message: String) : PrepareError() + } + + sealed class Status : Model() { + data class Completed(val createdAt: Long) : Status() + data class Error(val reason: String) : Status() + } + data class ConnectionState(val isAvailable: Boolean, val reason: Reason? = null) : Model() { sealed class Reason : Model() { data class ConnectionClosed(val message: String) : Reason() diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/WalletKit.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/WalletKit.kt index f7c377919..1e8bada50 100644 --- a/product/walletkit/src/main/kotlin/com/reown/walletkit/client/WalletKit.kt +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/client/WalletKit.kt @@ -3,14 +3,29 @@ package com.reown.walletkit.client import com.reown.android.Core import com.reown.android.CoreInterface import com.reown.android.internal.common.scope +import com.reown.android.internal.common.wcKoinApp import com.reown.sign.client.Sign import com.reown.sign.client.SignClient import com.reown.sign.common.exceptions.SignClientAlreadyInitializedException +import com.reown.walletkit.di.walletKitModule +import com.reown.walletkit.smart_account.Account +import com.reown.walletkit.smart_account.SafeInteractor +import com.reown.walletkit.use_cases.PrepareChainAbstractionUseCase +import com.reown.walletkit.use_cases.EstimateGasUseCase +import com.reown.walletkit.use_cases.ChainAbstractionStatusUseCase +import com.reown.walletkit.use_cases.GetERC20TokenBalanceUseCase +import com.reown.walletkit.use_cases.GetTransactionDetailsUseCase import kotlinx.coroutines.* import java.util.* object WalletKit { private lateinit var coreClient: CoreInterface + private lateinit var safeInteractor: SafeInteractor + private val prepareChainAbstractionUseCase: PrepareChainAbstractionUseCase by wcKoinApp.koin.inject() + private val chainAbstractionStatusUseCase: ChainAbstractionStatusUseCase by wcKoinApp.koin.inject() + private val estimateGasUseCase: EstimateGasUseCase by wcKoinApp.koin.inject() + private val getTransactionDetailsUseCase: GetTransactionDetailsUseCase by wcKoinApp.koin.inject() + private val getERC20TokenBalanceUseCase: GetERC20TokenBalanceUseCase by wcKoinApp.koin.inject() interface WalletDelegate { fun onSessionProposal(sessionProposal: Wallet.Model.SessionProposal, verifyContext: Wallet.Model.VerifyContext) @@ -96,7 +111,12 @@ object WalletKit { @Throws(IllegalStateException::class) fun initialize(params: Wallet.Params.Init, onSuccess: () -> Unit = {}, onError: (Wallet.Model.Error) -> Unit) { + wcKoinApp.modules(walletKitModule()) coreClient = params.core + if (params.pimlicoApiKey != null) { + safeInteractor = SafeInteractor(params.pimlicoApiKey) + } + SignClient.initialize(Sign.Params.Init(params.core), onSuccess = onSuccess) { error -> if (error.throwable is SignClientAlreadyInitializedException) { onSuccess() @@ -150,7 +170,7 @@ object WalletKit { onSuccess: (Wallet.Params.SessionApprove) -> Unit = {}, onError: (Wallet.Model.Error) -> Unit, ) { - val signParams = Sign.Params.Approve(params.proposerPublicKey, params.namespaces.toSign(), params.relayProtocol) + val signParams = Sign.Params.Approve(params.proposerPublicKey, params.namespaces.toSign(), params.properties, params.relayProtocol) SignClient.approveSession(signParams, { onSuccess(params) }, { error -> onError(Wallet.Model.Error(error.throwable)) }) } @@ -269,6 +289,109 @@ object WalletKit { SignClient.ping(signParams, signPingLister) } + //Yttrium + + @Throws(Throwable::class) + @SmartAccountExperimentalApi + fun getSmartAccount(params: Wallet.Params.GetSmartAccountAddress): String { + check(::safeInteractor.isInitialized) { "Smart Accounts are not enabled" } + + val client = safeInteractor.getOrCreate(Account(params.owner.address)) + return runBlocking { client.getAddress() } + } + + @Throws(Throwable::class) + @SmartAccountExperimentalApi + fun prepareSendTransactions(params: Wallet.Params.PrepareSendTransactions, onSuccess: (Wallet.Params.PrepareSendTransactionsResult) -> Unit) { + check(::safeInteractor.isInitialized) { "Smart Accounts are not enabled" } + + val client = safeInteractor.getOrCreate(Account(params.owner.address)) + scope.launch { + async { client.prepareSendTransactions(params.transactions.map { it.toYttrium() }).toWallet() } + .await() + .let(onSuccess) + } + } + + @Throws(Throwable::class) + @SmartAccountExperimentalApi + fun doSendTransactions(params: Wallet.Params.DoSendTransactions, onSuccess: (Wallet.Params.DoSendTransactionsResult) -> Unit) { + check(::safeInteractor.isInitialized) { "Smart Accounts are not enabled" } + + val client = safeInteractor.getOrCreate(Account(params.owner.address)) + scope.launch { + async { client.doSendTransactions(params.signatures.map { it.toYttrium() }, params.doSendTransactionParams) } + .await() + .let { userOpHash -> onSuccess(Wallet.Params.DoSendTransactionsResult(userOpHash)) } + } + } + + @Throws(Throwable::class) + @SmartAccountExperimentalApi + fun waitForUserOperationReceipt(params: Wallet.Params.WaitForUserOperationReceipt, onSuccess: (String) -> Unit) { + check(::safeInteractor.isInitialized) { "Smart Accounts are not enabled" } + + val client = safeInteractor.getOrCreate(Account(params.owner.address)) + scope.launch { + async { client.waitForUserOperationReceipt(params.userOperationHash) } + .await() + .let(onSuccess) + } + } + + //Chain Abstraction + @ChainAbstractionExperimentalApi + fun prepare( + initialTransaction: Wallet.Model.InitialTransaction, + onSuccess: (Wallet.Model.PrepareSuccess) -> Unit, + onError: (Wallet.Model.PrepareError) -> Unit + ) { + try { + prepareChainAbstractionUseCase(initialTransaction, onSuccess, onError) + } catch (e: Exception) { + onError(Wallet.Model.PrepareError.Unknown(e.message ?: "Unknown error")) + } + } + + @ChainAbstractionExperimentalApi + fun status( + fulfilmentId: String, + checkIn: Long, + onSuccess: (Wallet.Model.Status.Completed) -> Unit, + onError: (Wallet.Model.Status.Error) -> Unit + ) { + try { + chainAbstractionStatusUseCase(fulfilmentId, checkIn, onSuccess, onError) + } catch (e: Exception) { + onError(Wallet.Model.Status.Error(e.message ?: "Unknown error")) + } + } + + @Throws(Exception::class) + @ChainAbstractionExperimentalApi + fun estimateFees(chainId: String): Wallet.Model.EstimatedFees { + return estimateGasUseCase(chainId) + } + + @Throws(Exception::class) + @ChainAbstractionExperimentalApi + fun getERC20Balance(chainId: String, tokenAddress: String, ownerAddress: String): String { + return getERC20TokenBalanceUseCase(chainId, tokenAddress, ownerAddress) + } + + @ChainAbstractionExperimentalApi + fun getTransactionsDetails( + available: Wallet.Model.PrepareSuccess.Available, + onSuccess: (Wallet.Model.TransactionsDetails) -> Unit, + onError: (Wallet.Model.Error) -> Unit + ) { + try { + getTransactionDetailsUseCase(available, onSuccess, onError) + } catch (e: Exception) { + onError(Wallet.Model.Error(e)) + } + } + /** * Caution: This function is blocking and runs on the current thread. * It is advised that this function be called from background operation diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/di/WalletModule.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/di/WalletModule.kt new file mode 100644 index 000000000..c68cee3a7 --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/di/WalletModule.kt @@ -0,0 +1,25 @@ +package com.reown.walletkit.di + +import com.reown.android.internal.common.model.ProjectId +import com.reown.walletkit.use_cases.PrepareChainAbstractionUseCase +import com.reown.walletkit.use_cases.EstimateGasUseCase +import com.reown.walletkit.use_cases.ChainAbstractionStatusUseCase +import com.reown.walletkit.use_cases.GetERC20TokenBalanceUseCase +import com.reown.walletkit.use_cases.GetTransactionDetailsUseCase +import org.koin.dsl.module +import uniffi.uniffi_yttrium.ChainAbstractionClient + +@JvmSynthetic +internal fun walletKitModule() = module { + single { ChainAbstractionClient(get().value) } + + single { PrepareChainAbstractionUseCase(get()) } + + single { ChainAbstractionStatusUseCase(get()) } + + single { EstimateGasUseCase(get()) } + + single { GetTransactionDetailsUseCase(get()) } + + single { GetERC20TokenBalanceUseCase(get()) } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/Account.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/Account.kt new file mode 100644 index 000000000..861e1ef08 --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/Account.kt @@ -0,0 +1,11 @@ +package com.reown.walletkit.smart_account + +data class Account(val owner: String) { + val address: String + get() = owner.split(":").last() + val reference: String + get() = owner.split(":")[1] + + val namespace: String + get() = owner.split(":").first() +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/SafeInteractor.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/SafeInteractor.kt new file mode 100644 index 000000000..ee60eb5fb --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/smart_account/SafeInteractor.kt @@ -0,0 +1,44 @@ +package com.reown.walletkit.smart_account + +import com.reown.android.internal.common.model.ProjectId +import com.reown.android.internal.common.wcKoinApp +import uniffi.uniffi_yttrium.FfiAccountClient +import uniffi.uniffi_yttrium.FfiAccountClientConfig + +import uniffi.yttrium.Config +import uniffi.yttrium.Endpoint +import uniffi.yttrium.Endpoints + +class SafeInteractor(private val pimlicoApiKey: String) { + private val projectId: String = wcKoinApp.koin.get().value + private val ownerToAccountClient = mutableMapOf() + + fun getOrCreate(account: Account): FfiAccountClient { + return if (ownerToAccountClient.containsKey(account.owner)) { + ownerToAccountClient[account.owner]!! + } else { + val safeAccount = createSafeAccount(account) + ownerToAccountClient[account.owner] = safeAccount + safeAccount + } + } + + private fun createSafeAccount(account: Account): FfiAccountClient { + val pimlicoUrl = "https://api.pimlico.io/v2/${account.reference}/rpc?apikey=$pimlicoApiKey" + val endpoints = Endpoints( + rpc = Endpoint(baseUrl = "https://rpc.walletconnect.com/v1?chainId=${account.namespace}:${account.reference}&projectId=$projectId", apiKey = ""), + bundler = Endpoint(baseUrl = pimlicoUrl, apiKey = ""), //todo: remove apiKet from bindings + paymaster = Endpoint(baseUrl = pimlicoUrl, apiKey = ""), + ) + val config = Config(endpoints) + val accountConfig = FfiAccountClientConfig( + ownerAddress = account.address, + chainId = account.reference.toULong(), + config = config, + privateKey = "ff89825a799afce0d5deaa079cdde227072ec3f62973951683ac8cc033000000", //todo: remove sign service, just placeholder + safe = true, + signerType = "PrivateKey" //todo: remove sign service + ) + return FfiAccountClient(accountConfig) + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/ChainAbstractionStatusUseCase.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/ChainAbstractionStatusUseCase.kt new file mode 100644 index 000000000..ce6c35156 --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/ChainAbstractionStatusUseCase.kt @@ -0,0 +1,62 @@ +package com.reown.walletkit.use_cases + +import com.reown.android.internal.common.scope +import com.reown.walletkit.client.Wallet +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.StatusResponse + +class ChainAbstractionStatusUseCase(private val chainAbstractionClient: ChainAbstractionClient) { + operator fun invoke( + fulfilmentId: String, + checkIn: Long, + onSuccess: (Wallet.Model.Status.Completed) -> Unit, + onError: (Wallet.Model.Status.Error) -> Unit + ) { + scope.launch { + withTimeout(FULFILMENT_TIMEOUT) { + delay(checkIn) + + while (true) { + try { + val result = async { + try { + chainAbstractionClient.status(fulfilmentId) + } catch (e: Exception) { + return@async onError(Wallet.Model.Status.Error(e.message ?: "Unknown error")) + } + }.await() + + when (result) { + is StatusResponse.Completed -> { + onSuccess(Wallet.Model.Status.Completed(result.v1.createdAt.toLong())) + break + } + + is StatusResponse.Error -> { + onError(Wallet.Model.Status.Error(result.v1.error)) + break + } + + is StatusResponse.Pending -> { + delay(result.v1.checkIn.toLong()) + } + + else -> break + } + } catch (e: Exception) { + onError(Wallet.Model.Status.Error(e.message ?: "Unknown error")) + break + } + } + } + } + } + + private companion object { + const val FULFILMENT_TIMEOUT = 180000L + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/EstimateGasUseCase.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/EstimateGasUseCase.kt new file mode 100644 index 000000000..a2b7508ee --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/EstimateGasUseCase.kt @@ -0,0 +1,12 @@ +package com.reown.walletkit.use_cases + +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.toWallet +import kotlinx.coroutines.runBlocking +import uniffi.uniffi_yttrium.ChainAbstractionClient + +class EstimateGasUseCase(private val chainAbstractionClient: ChainAbstractionClient) { + operator fun invoke(chainId: String): Wallet.Model.EstimatedFees { + return runBlocking { chainAbstractionClient.estimateFees(chainId).toWallet() } + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetERC20TokenBalanceUseCase.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetERC20TokenBalanceUseCase.kt new file mode 100644 index 000000000..a05f43432 --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetERC20TokenBalanceUseCase.kt @@ -0,0 +1,10 @@ +package com.reown.walletkit.use_cases + +import kotlinx.coroutines.runBlocking +import uniffi.uniffi_yttrium.ChainAbstractionClient + +class GetERC20TokenBalanceUseCase(private val chainAbstractionClient: ChainAbstractionClient) { + operator fun invoke(chainId: String, tokenAddress: String, ownerAddress: String): String { + return runBlocking { chainAbstractionClient.erc20TokenBalance(chainId, tokenAddress, ownerAddress) } + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetTransactionDetailsUseCase.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetTransactionDetailsUseCase.kt new file mode 100644 index 000000000..ff9dc8abc --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/GetTransactionDetailsUseCase.kt @@ -0,0 +1,38 @@ +package com.reown.walletkit.use_cases + +import com.reown.android.internal.common.scope +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.toYttrium +import com.reown.walletkit.client.toWallet +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.Currency +import uniffi.yttrium.UiFields + +class GetTransactionDetailsUseCase(private val chainAbstractionClient: ChainAbstractionClient) { + operator fun invoke( + available: Wallet.Model.PrepareSuccess.Available, + onSuccess: (Wallet.Model.TransactionsDetails) -> Unit, + onError: (Wallet.Model.Error) -> Unit + ) { + scope.launch { + try { + val result = async { + try { + chainAbstractionClient.getUiFields(available.toYttrium(), Currency.USD) + } catch (e: Exception) { + return@async onError(Wallet.Model.Error(e)) + } + }.await() + + if (result is UiFields) { + onSuccess((result).toWallet()) + } + + } catch (e: Exception) { + onError(Wallet.Model.Error(e)) + } + } + } +} \ No newline at end of file diff --git a/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/PrepareChainAbstractionUseCase.kt b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/PrepareChainAbstractionUseCase.kt new file mode 100644 index 000000000..1f6183aaf --- /dev/null +++ b/product/walletkit/src/main/kotlin/com/reown/walletkit/use_cases/PrepareChainAbstractionUseCase.kt @@ -0,0 +1,54 @@ +package com.reown.walletkit.use_cases + +import com.reown.android.internal.common.scope +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.toInitialYttrium +import com.reown.walletkit.client.toWallet +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import uniffi.uniffi_yttrium.ChainAbstractionClient +import uniffi.yttrium.BridgingError +import uniffi.yttrium.PrepareResponse +import uniffi.yttrium.RouteResponseSuccess + +class PrepareChainAbstractionUseCase(private val chainAbstractionClient: ChainAbstractionClient) { + operator fun invoke( + initialTransaction: Wallet.Model.InitialTransaction, + onSuccess: (Wallet.Model.PrepareSuccess) -> Unit, + onError: (Wallet.Model.PrepareError) -> Unit + ) { + scope.launch { + try { + val result = async { + try { + chainAbstractionClient.prepare(initialTransaction.toInitialYttrium()) + } catch (e: Exception) { + return@async onError(Wallet.Model.PrepareError.Unknown(e.message ?: "Unknown error")) + } + }.await() + + when (result) { + is PrepareResponse.Success -> { + when (result.v1) { + is RouteResponseSuccess.Available -> + onSuccess((result.v1 as RouteResponseSuccess.Available).v1.toWallet()) + + is RouteResponseSuccess.NotRequired -> + onSuccess(Wallet.Model.PrepareSuccess.NotRequired((result.v1 as RouteResponseSuccess.NotRequired).v1.initialTransaction.toWallet())) + } + } + + is PrepareResponse.Error -> { + when (result.v1.error) { + BridgingError.NO_ROUTES_AVAILABLE -> onError(Wallet.Model.PrepareError.NoRoutesAvailable) + BridgingError.INSUFFICIENT_FUNDS -> onError(Wallet.Model.PrepareError.InsufficientFunds) + BridgingError.INSUFFICIENT_GAS_FUNDS -> onError(Wallet.Model.PrepareError.InsufficientGasFunds) + } + } + } + } catch (e: Exception) { + onError(Wallet.Model.PrepareError.Unknown(e.message ?: "Unknown error")) + } + } + } +} \ No newline at end of file diff --git a/product/walletkit/web3wallet-rules.pro b/product/walletkit/web3wallet-rules.pro index d7bb92f85..3a956fec4 100644 --- a/product/walletkit/web3wallet-rules.pro +++ b/product/walletkit/web3wallet-rules.pro @@ -1,4 +1,29 @@ -keep class com.reown.walletkit.client.Wallet$Model$Cacao$Signature { *; } -keep class com.reown.walletkit.client.Wallet$Model$Cacao { *; } -keep class com.reown.walletkit.client.Wallet$Model { *; } --keep class com.reown.walletkit.client.Wallet { *; } \ No newline at end of file +-keep class com.reown.walletkit.client.Wallet { *; } + +# Preserve all annotations (JNA and other libraries) +-keepattributes *Annotation* + +# Keep all JNA-related classes and methods +-keep class com.sun.jna.** { *; } +-keepclassmembers class com.sun.jna.** { + native ; + *; +} + +# Preserve the uniffi generated classes +-keep class uniffi.** { *; } + +# Preserve all public and protected fields and methods +-keepclassmembers class ** { + public *; + protected *; +} + +# Disable warnings for uniffi and JNA +-dontwarn uniffi.** +-dontwarn com.sun.jna.** + + diff --git a/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt b/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt index 383f412fc..f771ff0c3 100644 --- a/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt +++ b/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt @@ -343,6 +343,7 @@ object Sign { data class Approve( val proposerPublicKey: String, val namespaces: Map, + val properties: Map? = null, val relayProtocol: String? = null, ) : Params() diff --git a/protocol/sign/src/main/kotlin/com/reown/sign/client/SignProtocol.kt b/protocol/sign/src/main/kotlin/com/reown/sign/client/SignProtocol.kt index 516ec2a39..fbfa6910d 100644 --- a/protocol/sign/src/main/kotlin/com/reown/sign/client/SignProtocol.kt +++ b/protocol/sign/src/main/kotlin/com/reown/sign/client/SignProtocol.kt @@ -181,6 +181,7 @@ class SignProtocol(private val koinApp: KoinApplication = wcKoinApp) : SignInter signEngine.approve( proposerPublicKey = approve.proposerPublicKey, sessionNamespaces = approve.namespaces.toMapOfEngineNamespacesSession(), + sessionProperties = approve.properties, onSuccess = { onSuccess(approve) }, onFailure = { error -> onError(Sign.Model.Error(error)) } ) diff --git a/protocol/sign/src/main/kotlin/com/reown/sign/engine/model/mapper/EngineMapper.kt b/protocol/sign/src/main/kotlin/com/reown/sign/engine/model/mapper/EngineMapper.kt index c988fa822..193fa84cd 100644 --- a/protocol/sign/src/main/kotlin/com/reown/sign/engine/model/mapper/EngineMapper.kt +++ b/protocol/sign/src/main/kotlin/com/reown/sign/engine/model/mapper/EngineMapper.kt @@ -159,6 +159,7 @@ internal fun ProposalVO.toSessionSettleParams( selfParticipant: SessionParticipant, sessionExpiry: Long, namespaces: Map, + properties: Map? ): SignParams.SessionSettleParams = SignParams.SessionSettleParams( relay = RelayProtocolOptions(relayProtocol, relayData), diff --git a/protocol/sign/src/main/kotlin/com/reown/sign/engine/use_case/calls/ApproveSessionUseCase.kt b/protocol/sign/src/main/kotlin/com/reown/sign/engine/use_case/calls/ApproveSessionUseCase.kt index ee94a5c1b..3f8b805ea 100644 --- a/protocol/sign/src/main/kotlin/com/reown/sign/engine/use_case/calls/ApproveSessionUseCase.kt +++ b/protocol/sign/src/main/kotlin/com/reown/sign/engine/use_case/calls/ApproveSessionUseCase.kt @@ -55,6 +55,7 @@ internal class ApproveSessionUseCase( override suspend fun approve( proposerPublicKey: String, sessionNamespaces: Map, + sessionProperties: Map?, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit ) = supervisorScope { @@ -70,7 +71,7 @@ internal class ApproveSessionUseCase( metadataStorageRepository.insertOrAbortMetadata(sessionTopic, selfAppMetaData, AppMetaDataType.SELF) metadataStorageRepository.insertOrAbortMetadata(sessionTopic, proposal.appMetaData, AppMetaDataType.PEER) trace.add(Trace.Session.STORE_SESSION) - val params = proposal.toSessionSettleParams(selfParticipant, sessionExpiry, sessionNamespaces) + val params = proposal.toSessionSettleParams(selfParticipant, sessionExpiry, sessionNamespaces, sessionProperties) val sessionSettle = SignRpc.SessionSettle(params = params) val irnParams = IrnParams(Tags.SESSION_SETTLE, Ttl(fiveMinutesInSeconds)) trace.add(Trace.Session.PUBLISHING_SESSION_SETTLE).also { logger.log("Publishing session settle on topic: $sessionTopic") } @@ -180,6 +181,7 @@ internal interface ApproveSessionUseCaseInterface { suspend fun approve( proposerPublicKey: String, sessionNamespaces: Map, + sessionProperties: Map? = null, onSuccess: () -> Unit = {}, onFailure: (Throwable) -> Unit = {}, ) diff --git a/sample/common/src/main/kotlin/com/reown/sample/common/Chains.kt b/sample/common/src/main/kotlin/com/reown/sample/common/Chains.kt index eb73955d8..f1a6ed0d3 100644 --- a/sample/common/src/main/kotlin/com/reown/sample/common/Chains.kt +++ b/sample/common/src/main/kotlin/com/reown/sample/common/Chains.kt @@ -15,7 +15,7 @@ fun getEthSignBody(account: String): String { } fun getEthSendTransaction(account: String): String { - return "[{\"from\":\"$account\",\"to\":\"0x70012948c348CBF00806A3C79E3c5DAdFaAa347B\",\"data\":\"0x\",\"gasLimit\":\"0x5208\",\"gasPrice\":\"0x0649534e00\",\"value\":\"0x01\",\"nonce\":\"0x07\"}]" + return "[{\"from\":\"$account\",\"to\":\"0x70012948c348CBF00806A3C79E3c5DAdFaAa347B\",\"data\":\"0x\",\"gasLimit\":\"0x5208\",\"gasPrice\":\"0x0649534e00\",\"value\":\"0\",\"nonce\":\"0x07\"}]" } fun getEthSignTypedData(account: String): String { diff --git a/sample/wallet/build.gradle.kts b/sample/wallet/build.gradle.kts index c835b4d95..e22befedf 100644 --- a/sample/wallet/build.gradle.kts +++ b/sample/wallet/build.gradle.kts @@ -22,6 +22,7 @@ android { useSupportLibrary = true } buildConfigField("String", "PROJECT_ID", "\"${System.getenv("WC_CLOUD_PROJECT_ID") ?: ""}\"") + buildConfigField("String", "PIMLICO_API_KEY", "\"${System.getenv("PIMLICO_API_KEY") ?: ""}\"") buildConfigField("String", "BOM_VERSION", "\"${BOM_VERSION}\"") } @@ -73,6 +74,16 @@ dependencies { implementation(project(":sample:common")) implementation("androidx.compose.material3:material3:1.0.0-alpha08") + implementation("org.web3j:core:4.9.4") + + // Retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + // Converter for JSON parsing using Gson + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + // OkHttp logging interceptor (optional, for debugging) + implementation("com.squareup.okhttp3:logging-interceptor:4.9.3") + implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2") + implementation(platform(libs.firebase.bom)) implementation(libs.bundles.firebase) @@ -134,4 +145,4 @@ dependencies { releaseImplementation("com.reown:android-core") releaseImplementation("com.reown:walletkit") releaseImplementation("com.reown:notify") -} +} \ No newline at end of file diff --git a/sample/wallet/proguard-rules.pro b/sample/wallet/proguard-rules.pro index 25ef9e66e..2b85c10bf 100644 --- a/sample/wallet/proguard-rules.pro +++ b/sample/wallet/proguard-rules.pro @@ -4,4 +4,123 @@ -keep class com.reown.walletkit.client.Wallet$Model$Cacao$Signature { *; } -keep class com.reown.walletkit.client.Wallet$Model$Cacao { *; } -keep class com.reown.walletkit.client.Wallet$Model { *; } --keep class com.reown.walletkit.client.Wallet { *; } \ No newline at end of file +-keep class com.reown.walletkit.client.Wallet { *; } + +# Preserve all annotations (JNA and other libraries) +-keepattributes *Annotation* + +# Keep all JNA-related classes and methods +-keep class com.sun.jna.** { *; } +-keepclassmembers class com.sun.jna.** { + native ; + *; +} + +# Preserve the uniffi generated classes +-keep class uniffi.** { *; } + +# Preserve all public and protected fields and methods +-keepclassmembers class ** { + public *; + protected *; +} + +# Disable warnings for uniffi and JNA +-dontwarn uniffi.** +-dontwarn com.sun.jna.** + +############################################## + +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Keep annotation default values (e.g., retrofit2.http.Field.encoded). +-keepattributes AnnotationDefault + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Ignore annotation used for build tooling. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Ignore JSR 305 annotations for embedding nullability information. +-dontwarn javax.annotation.** + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# Keep inherited services. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface * extends <1> + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# R8 full mode strips generic signatures from return types if not kept. +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +# With R8 full mode generic signatures are stripped for classes that are not kept. +-keep,allowobfuscation,allowshrinking class retrofit2.Response + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +# JSR 305 annotations are for embedding nullability information. +-dontwarn javax.annotation.** + +# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. +-dontwarn org.codehaus.mojo.animal_sniffer.* + +# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + +# Keep model classes used by Gson (adjust the package and class names accordingly) +-keep class com.reown.sample.wallet.blockchain.JsonRpcRequest { *; } +-keep class com.reown.sample.wallet.blockchain.JsonRpcRequest$* { *; } # if inner classes exist + +-keep class com.reown.sample.wallet.blockchain.JsonRpcResponse { *; } +-keep class com.reown.sample.wallet.blockchain.JsonRpcError { *; } # if inner classes exist diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/WalletKitApplication.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/WalletKitApplication.kt index a1f48fafd..4b306e0f7 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/WalletKitApplication.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/WalletKitApplication.kt @@ -1,7 +1,6 @@ package com.reown.sample.wallet import android.app.Application -import com.google.firebase.FirebaseApp import com.google.firebase.appdistribution.FirebaseAppDistribution import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase @@ -21,6 +20,7 @@ import com.reown.notify.client.NotifyClient import com.reown.sample.wallet.domain.EthAccountDelegate import com.reown.sample.wallet.domain.NotificationHandler import com.reown.sample.wallet.domain.NotifyDelegate +import com.reown.sample.wallet.domain.SmartAccountEnabler import com.reown.sample.wallet.domain.mixPanel import com.reown.sample.wallet.ui.state.ConnectionState import com.reown.sample.wallet.ui.state.connectionStateFlow @@ -36,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.koin.core.qualifier.named import timber.log.Timber +//import uniffi.uniffi_yttrium.AccountClient import com.reown.sample.common.BuildConfig as CommonBuildConfig class WalletKitApplication : Application() { @@ -45,6 +46,8 @@ class WalletKitApplication : Application() { super.onCreate() EthAccountDelegate.application = this + SmartAccountEnabler.init(this) + val projectId = BuildConfig.PROJECT_ID val appMetaData = Core.Model.AppMetaData( name = "Kotlin Wallet", @@ -71,7 +74,7 @@ class WalletKitApplication : Application() { println("Account: ${EthAccountDelegate.account}") - WalletKit.initialize(Wallet.Params.Init(core = CoreClient), + WalletKit.initialize(Wallet.Params.Init(core = CoreClient, pimlicoApiKey = BuildConfig.PIMLICO_API_KEY), onSuccess = { println("Web3Wallet initialized") }, onError = { error -> Firebase.crashlytics.recordException(error.throwable) @@ -79,7 +82,6 @@ class WalletKitApplication : Application() { }) FirebaseAppDistribution.getInstance().updateIfNewReleaseAvailable() - NotifyClient.initialize( init = Notify.Params.Init(CoreClient) ) { error -> diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/BlockChainApiService.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/BlockChainApiService.kt new file mode 100644 index 000000000..3776dc918 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/BlockChainApiService.kt @@ -0,0 +1,9 @@ +package com.reown.sample.wallet.blockchain + +import retrofit2.http.Body +import retrofit2.http.POST + +interface BlockChainApiService { + @POST("/v1") + suspend fun sendJsonRpcRequest(@Body request: JsonRpcRequest): JsonRpcResponse +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcRequest.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcRequest.kt new file mode 100644 index 000000000..964a63053 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcRequest.kt @@ -0,0 +1,16 @@ +package com.reown.sample.wallet.blockchain + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class JsonRpcRequest( + @SerializedName("jsonrpc") + val jsonrpc: String = "2.0", + @SerializedName("method") + val method: String, + @SerializedName("params") + val params: List, + @SerializedName("id") + val id: Int +) \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcResponse.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcResponse.kt new file mode 100644 index 000000000..4bec92c00 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/JsonRpcResponse.kt @@ -0,0 +1,23 @@ +package com.reown.sample.wallet.blockchain + +import com.google.gson.annotations.SerializedName + +data class JsonRpcResponse( + @SerializedName("jsonrpc") + val jsonrpc: String, + @SerializedName("id") + val id: Int, + @SerializedName("result") + val result: T?, + @SerializedName("error") + val error: JsonRpcError? +) + +data class JsonRpcError( + @SerializedName("code") + val code: Int, + @SerializedName("message") + val message: String, + @SerializedName("data") + val data: Any? +) \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/Retrofit.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/Retrofit.kt new file mode 100644 index 000000000..2193f9ff0 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/blockchain/Retrofit.kt @@ -0,0 +1,52 @@ +package com.reown.sample.wallet.blockchain + +import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +fun createBlockChainApiService(projectId: String, chainId: String): BlockChainApiService { + val rpcUrl: String = when (chainId) { + "eip155:10" -> "https://rpc.walletconnect.com"//"https://mainnet.optimism.io" + "eip155:8453" -> "https://mainnet.base.org" + "eip155:42161" -> "https://rpc.walletconnect.com"//""https://arbitrum.llamarpc.com" + else -> "https://rpc.walletconnect.com" + } + + val httpClient = OkHttpClient.Builder() + + // Logging interceptor (optional) + val logging = HttpLoggingInterceptor() + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + httpClient.addInterceptor(logging) + + // Interceptor to add query parameters + val queryParameterInterceptor = Interceptor { chain -> + val originalRequest = chain.request() + val originalHttpUrl = originalRequest.url + + val newUrl = originalHttpUrl.newBuilder() + .addQueryParameter("chainId", chainId) + .addQueryParameter("projectId", projectId) + .build() + + val newRequest = originalRequest.newBuilder() + .url(newUrl) + .build() + + chain.proceed(newRequest) + } + + httpClient.addInterceptor(queryParameterInterceptor) + + val retrofit = Retrofit.Builder() + .baseUrl(rpcUrl) + .client(httpClient.build()) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) + .build() + + return retrofit.create(BlockChainApiService::class.java) +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/ChainAbstractionUtils.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/ChainAbstractionUtils.kt new file mode 100644 index 000000000..34b91ce6b --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/ChainAbstractionUtils.kt @@ -0,0 +1,185 @@ +@file:OptIn(ChainAbstractionExperimentalApi::class) + +package com.reown.sample.wallet.domain + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.reown.sample.wallet.domain.WCDelegate._walletEvents +import com.reown.sample.wallet.domain.WCDelegate.scope +import com.reown.walletkit.client.ChainAbstractionExperimentalApi +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@OptIn(ChainAbstractionExperimentalApi::class) +fun prepare(sessionRequest: Wallet.Model.SessionRequest, initialTransaction: Wallet.Model.InitialTransaction, verifyContext: Wallet.Model.VerifyContext) { + try { + WalletKit.prepare( + initialTransaction, + onSuccess = { result -> + when (result) { + is Wallet.Model.PrepareSuccess.Available -> { + println("Prepare success available: $result") + emitChainAbstractionRequest(sessionRequest, result, verifyContext) + } + + is Wallet.Model.PrepareSuccess.NotRequired -> { + println("Prepare success not required: $result") + emitSessionRequest(sessionRequest, verifyContext) + } + } + }, + onError = { error -> + println("Prepare error: $error") + respondWithError(getErrorMessage(), sessionRequest) + emitChainAbstractionError(sessionRequest, error, verifyContext) + } + ) + } catch (e: Exception) { + println("Prepare: Unknown error: $e") + respondWithError(e.message ?: "Prepare: Unknown error", sessionRequest) + emitChainAbstractionError(sessionRequest, Wallet.Model.PrepareError.Unknown(e.message ?: "Prepare: Unknown error"), verifyContext) + } +} + +fun respondWithError(errorMessage: String, sessionRequest: Wallet.Model.SessionRequest?) { + if (sessionRequest != null) { + val result = Wallet.Params.SessionRequestResponse( + sessionTopic = sessionRequest.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = sessionRequest.request.id, + code = 500, + message = errorMessage + ) + ) + try { + WalletKit.respondSessionRequest(result, + onSuccess = { + println("Error sent success") + clearSessionRequest() + }, + onError = { error -> + println("Error sent error: $error") + recordError(error.throwable) + }) + } catch (e: Exception) { + Firebase.crashlytics.recordException(e) + } + } +} + +suspend fun getTransactionsDetails(): Result = + suspendCoroutine { continuation -> + try { + WalletKit.getTransactionsDetails( + WCDelegate.fulfilmentAvailable!!, + onSuccess = { + println("Transaction details SUCCESS: $it") + continuation.resume(Result.success(it)) + }, + onError = { + println("Transaction details ERROR: $it") + recordError(Throwable(it.throwable)) + continuation.resume(Result.failure(it.throwable)) + } + ) + } catch (e: Exception) { + println("Transaction details utils: $e") + recordError(e) + continuation.resume(Result.failure(e)) + } + } + +suspend fun status(): Result = + suspendCoroutine { continuation -> + try { + WalletKit.status( + WCDelegate.fulfilmentAvailable!!.fulfilmentId, + WCDelegate.fulfilmentAvailable!!.checkIn, + onSuccess = { + println("Fulfilment status SUCCESS: $it") + continuation.resume(Result.success(it)) + }, + onError = { + println("Fulfilment status ERROR: $it") + recordError(Throwable(it.reason)) + continuation.resume(Result.failure(Exception(it.reason))) + } + ) + } catch (e: Exception) { + println("Catch status utils: $e") + recordError(e) + continuation.resume(Result.failure(e)) + } + } + +fun emitSessionRequest(sessionRequest: Wallet.Model.SessionRequest, verifyContext: Wallet.Model.VerifyContext) { + if (WCDelegate.currentId != sessionRequest.request.id) { + WCDelegate.sessionRequestEvent = Pair(sessionRequest, verifyContext) + + scope.launch { + _walletEvents.emit(sessionRequest) + } + } +} + +fun emitChainAbstractionRequest(sessionRequest: Wallet.Model.SessionRequest, fulfilment: Wallet.Model.PrepareSuccess.Available, verifyContext: Wallet.Model.VerifyContext) { + if (WCDelegate.currentId != sessionRequest.request.id) { + WCDelegate.sessionRequestEvent = Pair(sessionRequest, verifyContext) + WCDelegate.fulfilmentAvailable = fulfilment + + scope.launch { + async { getTransactionsDetails() }.await().fold( + onSuccess = { WCDelegate.transactionsDetails = it }, + onFailure = { error -> println("Failed getting tx details: $error") } + ) + + _walletEvents.emit(fulfilment) + } + } +} + +fun emitChainAbstractionError(sessionRequest: Wallet.Model.SessionRequest, prepareError: Wallet.Model.PrepareError, verifyContext: Wallet.Model.VerifyContext) { + if (WCDelegate.currentId != sessionRequest.request.id) { + WCDelegate.sessionRequestEvent = Pair(sessionRequest, verifyContext) + WCDelegate.prepareError = prepareError + recordError(Throwable(getErrorMessage())) + + scope.launch { + _walletEvents.emit(prepareError) + } + } +} + +fun getErrorMessage(): String { + return when (val error = WCDelegate.prepareError) { + Wallet.Model.PrepareError.InsufficientFunds -> "Insufficient funds" + Wallet.Model.PrepareError.InsufficientGasFunds -> "Insufficient gas funds" + Wallet.Model.PrepareError.NoRoutesAvailable -> "No routes available" + is Wallet.Model.PrepareError.Unknown -> error.message + else -> "Unknown Error" + } +} + +fun getUSDCContractAddress(chainId: String): String { + return when (chainId) { + "eip155:10" -> "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85" + "eip155:8453" -> "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + "eip155:42161" -> "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + else -> "" + } +} + +fun clearSessionRequest() { + WCDelegate.sessionRequestEvent = null + WCDelegate.currentId = null +// sessionRequestUI = SessionRequestUI.Initial +} + +fun recordError(throwable: Throwable) { + mixPanel.track("error: $throwable; errorMessage: ${throwable.message}") + Firebase.crashlytics.recordException(throwable) +} diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthAccountDelegate.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthAccountDelegate.kt index eff070699..9ca15f1f0 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthAccountDelegate.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthAccountDelegate.kt @@ -29,6 +29,9 @@ object EthAccountDelegate { val ethAddress: String get() = "eip155:1:$account" + val sepoliaAddress: String + get() = "eip155:11155111:$account" + val account: String get() = if (isInitialized) sharedPreferences.getString(ACCOUNT_TAG, null)!! else storeAccount().third diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthSigner.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthSigner.kt new file mode 100644 index 000000000..fe83a1da1 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/EthSigner.kt @@ -0,0 +1,32 @@ +package com.reown.sample.wallet.domain + +import com.reown.android.cacao.signature.SignatureType +import com.reown.android.utils.cacao.sign +import com.reown.util.bytesToHex +import com.reown.util.hexToBytes +import com.reown.walletkit.utils.CacaoSigner +import org.web3j.crypto.ECKeyPair +import org.web3j.crypto.Sign + +object EthSigner { + + fun personalSign(message: String): String = CacaoSigner.sign(message, EthAccountDelegate.privateKey.hexToBytes(), SignatureType.EIP191).s + + fun signHash(hashToSign: String, privateKey: String): String { + val dataToSign: ByteArray = if (hashToSign.startsWith("0x")) { + hashToSign.drop(2).hexToBytes() + } else { + hashToSign.toByteArray(Charsets.UTF_8) + } + + val ecKeyPair = ECKeyPair.create(privateKey.hexToBytes()) + val signatureData = Sign.signMessage(dataToSign, ecKeyPair, false) + val rHex = signatureData.r.bytesToHex() + val sHex = signatureData.s.bytesToHex() + val vByte = signatureData.v[0] + val v = (vByte.toInt() and 0xFF) + val vHex = v.toString(16) + val result = "0x$rHex$sHex$vHex" + return result + } +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/Signer.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/Signer.kt new file mode 100644 index 000000000..1eff55b50 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/Signer.kt @@ -0,0 +1,131 @@ +package com.reown.sample.wallet.domain + +import com.reown.sample.common.Chains +import com.reown.sample.wallet.domain.model.Transaction +import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request.SessionRequestUI +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import org.json.JSONArray +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object Signer { + suspend fun sign(sessionRequest: SessionRequestUI.Content): String = supervisorScope { + when { + SmartAccountEnabler.isSmartAccountEnabled.value -> when (sessionRequest.method) { + "wallet_sendCalls" -> { + val transactions: MutableList = mutableListOf() + val callsArray = JSONArray(sessionRequest.param).getJSONObject(0).getJSONArray("calls") + for (i in 0 until callsArray.length()) { + val call = callsArray.getJSONObject(i) + val to = call.getString("to") ?: "" + val value = call.getString("value") ?: "" + val data = try { + call.getString("data") + } catch (e: Exception) { + "" + } + transactions.add(Wallet.Params.Transaction(to, value, data)) + } + val ownerAccount = Wallet.Params.Account(EthAccountDelegate.sepoliaAddress) + val prepareSendTxsParams = Wallet.Params.PrepareSendTransactions(transactions = transactions, owner = ownerAccount) + + val prepareTxsResult = async { prepareTransactions(prepareSendTxsParams) }.await().getOrThrow() + val signature = EthSigner.signHash(prepareTxsResult.hash, EthAccountDelegate.privateKey) + val doSendTxsParams = Wallet.Params.DoSendTransactions( + owner = ownerAccount, + signatures = listOf(Wallet.Params.OwnerSignature(address = EthAccountDelegate.account, signature = signature)), + doSendTransactionParams = prepareTxsResult.doSendTransactionParams + ) + val doTxsResult = async { doTransactions(doSendTxsParams) }.await().getOrThrow() + val userOperationReceiptParam = Wallet.Params.WaitForUserOperationReceipt(owner = ownerAccount, userOperationHash = doTxsResult.userOperationHash) + + val userOperationReceipt = waitForUserOperationReceipt(userOperationReceiptParam) + println("userOperationReceipt: $userOperationReceipt") + + doTxsResult.userOperationHash + } + + ETH_SEND_TRANSACTION -> { + val transactions: MutableList = mutableListOf() + val params = JSONArray(sessionRequest.param).getJSONObject(0) + val to = params.getString("to") ?: "" + val value = params.getString("value") ?: "" + val data = try { + params.getString("data") + } catch (e: Exception) { + "" + } + + transactions.add(Wallet.Params.Transaction(to, value, data)) + val ownerAccount = Wallet.Params.Account(EthAccountDelegate.sepoliaAddress) + val prepareSendTxsParams = Wallet.Params.PrepareSendTransactions(transactions = transactions, owner = ownerAccount) + val prepareTxsResult = async { prepareTransactions(prepareSendTxsParams) }.await().getOrThrow() + val signature = EthSigner.signHash(prepareTxsResult.hash, EthAccountDelegate.privateKey) + val doSendTxsParams = Wallet.Params.DoSendTransactions( + owner = ownerAccount, + signatures = listOf(Wallet.Params.OwnerSignature(address = EthAccountDelegate.account, signature = signature)), + doSendTransactionParams = prepareTxsResult.doSendTransactionParams + ) + val doTxsResult = async { doTransactions(doSendTxsParams) }.await().getOrThrow() + doTxsResult.userOperationHash + } + + else -> throw Exception("Unsupported Method") + } + + !SmartAccountEnabler.isSmartAccountEnabled.value -> when { + sessionRequest.method == PERSONAL_SIGN -> EthSigner.personalSign(sessionRequest.param) + sessionRequest.method == ETH_SEND_TRANSACTION -> { + val txHash = Transaction.send(WCDelegate.sessionRequestEvent!!.first) + txHash + } + //Note: Only for testing purposes - it will always fail on Dapp side + sessionRequest.chain?.contains(Chains.Info.Eth.chain, true) == true -> + """0xa3f20717a250c2b0b729b7e5becbff67fdaef7e0699da4de7ca5895b02a170a12d887fd3b17bfdce3481f10bea41f45ba9f709d39ce8325427b57afcfc994cee1b""" + //Note: Only for testing purposes - it will always fail on Dapp side + sessionRequest.chain?.contains(Chains.Info.Cosmos.chain, true) == true -> + """{"signature":"pBvp1bMiX6GiWmfYmkFmfcZdekJc19GbZQanqaGa\/kLPWjoYjaJWYttvm17WoDMyn4oROas4JLu5oKQVRIj911==","pub_key":{"value":"psclI0DNfWq6cOlGrKD9wNXPxbUsng6Fei77XjwdkPSt","type":"tendermint\/PubKeySecp256k1"}}""" + //Note: Only for testing purposes - it will always fail on Dapp side + sessionRequest.chain?.contains(Chains.Info.Solana.chain, true) == true -> + """{"signature":"pBvp1bMiX6GiWmfYmkFmfcZdekJc19GbZQanqaGa\/kLPWjoYjaJWYttvm17WoDMyn4oROas4JLu5oKQVRIj911==","pub_key":{"value":"psclI0DNfWq6cOlGrKD9wNXPxbUsng6Fei77XjwdkPSt","type":"tendermint\/PubKeySecp256k1"}}""" + + else -> throw Exception("Unsupported Method") + } + + else -> throw Exception("Unsupported Chain") + } + } + + private suspend fun prepareTransactions(params: Wallet.Params.PrepareSendTransactions): Result = + suspendCoroutine { continuation -> + try { + WalletKit.prepareSendTransactions(params) { result -> continuation.resume(Result.success(result)) } + } catch (e: Exception) { + continuation.resume(Result.failure(e)) + } + } + + private suspend fun doTransactions(params: Wallet.Params.DoSendTransactions): Result = + suspendCoroutine { continuation -> + try { + WalletKit.doSendTransactions(params) { result -> continuation.resume(Result.success(result)) } + } catch (e: Exception) { + continuation.resume(Result.failure(e)) + } + } + + private suspend fun waitForUserOperationReceipt(params: Wallet.Params.WaitForUserOperationReceipt): Result = + suspendCoroutine { continuation -> + try { + WalletKit.waitForUserOperationReceipt(params) { result -> continuation.resume(Result.success(result)) } + } catch (e: Exception) { + continuation.resume(Result.failure(e)) + } + } + + const val PERSONAL_SIGN = "personal_sign" + const val ETH_SEND_TRANSACTION = "eth_sendTransaction" +} diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/SmartAccountEnabler.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/SmartAccountEnabler.kt new file mode 100644 index 000000000..892c1f968 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/SmartAccountEnabler.kt @@ -0,0 +1,34 @@ +package com.reown.sample.wallet.domain + +import android.content.Context +import android.content.SharedPreferences +import com.reown.android.internal.common.scope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope + +const val SA_PREFS = "sa_prefs" +const val SAFE_ENABLED_KEY = "safe_enabled" + +object SmartAccountEnabler { + private lateinit var context: Context + private lateinit var sharedPrefs: SharedPreferences + private lateinit var _isSmartAccountEnabled: MutableStateFlow + val isSmartAccountEnabled by lazy { _isSmartAccountEnabled.asStateFlow() } + + fun init(context: Context) { + this.context = context + this.sharedPrefs = context.getSharedPreferences(SA_PREFS, Context.MODE_PRIVATE) + this._isSmartAccountEnabled = MutableStateFlow(sharedPrefs.getBoolean(SAFE_ENABLED_KEY, false)) + } + + fun enableSmartAccount(isEnabled: Boolean) { + _isSmartAccountEnabled.value = isEnabled + scope.launch { + supervisorScope { + sharedPrefs.edit().putBoolean(SAFE_ENABLED_KEY, isEnabled).apply() + } + } + } +} diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/WCDelegate.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/WCDelegate.kt index 64e7b500e..a16cad7fc 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/WCDelegate.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/WCDelegate.kt @@ -3,6 +3,7 @@ package com.reown.sample.wallet.domain import android.util.Log import com.reown.android.Core import com.reown.android.CoreClient +import com.reown.sample.wallet.domain.model.Transaction.getInitialTransaction import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import kotlinx.coroutines.CoroutineScope @@ -15,11 +16,11 @@ import kotlinx.coroutines.launch import org.json.JSONObject object WCDelegate : WalletKit.WalletDelegate, CoreClient.CoreDelegate { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _coreEvents: MutableSharedFlow = MutableSharedFlow() val coreEvents: SharedFlow = _coreEvents.asSharedFlow() - private val _walletEvents: MutableSharedFlow = MutableSharedFlow() + internal val _walletEvents: MutableSharedFlow = MutableSharedFlow() val walletEvents: SharedFlow = _walletEvents.asSharedFlow() private val _connectionState: MutableSharedFlow = MutableSharedFlow(replay = 1) val connectionState: SharedFlow = _connectionState.asSharedFlow() @@ -27,6 +28,10 @@ object WCDelegate : WalletKit.WalletDelegate, CoreClient.CoreDelegate { var sessionAuthenticateEvent: Pair? = null var sessionRequestEvent: Pair? = null var currentId: Long? = null + //CA + var fulfilmentAvailable: Wallet.Model.PrepareSuccess.Available? = null + var prepareError: Wallet.Model.PrepareError? = null + var transactionsDetails: Wallet.Model.TransactionsDetails? = null init { CoreClient.setDelegate(this) @@ -74,12 +79,11 @@ object WCDelegate : WalletKit.WalletDelegate, CoreClient.CoreDelegate { } override fun onSessionRequest(sessionRequest: Wallet.Model.SessionRequest, verifyContext: Wallet.Model.VerifyContext) { - if (currentId != sessionRequest.request.id) { - sessionRequestEvent = Pair(sessionRequest, verifyContext) - - scope.launch { - _walletEvents.emit(sessionRequest) - } + println("Request: $sessionRequest") + if (sessionRequest.request.method == "eth_sendTransaction") { + prepare(sessionRequest, getInitialTransaction(sessionRequest), verifyContext) + } else { + emitSessionRequest(sessionRequest, verifyContext) } } @@ -108,7 +112,7 @@ object WCDelegate : WalletKit.WalletDelegate, CoreClient.CoreDelegate { } override fun onPairingDelete(deletedPairing: Core.Model.DeletedPairing) { - //Deprecated - pairings are automatically deleted + //Deprecated - pairings are automatically deleted } override fun onPairingExpired(expiredPairing: Core.Model.ExpiredPairing) { diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/NetworkUtils.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/NetworkUtils.kt new file mode 100644 index 000000000..63944f2ba --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/NetworkUtils.kt @@ -0,0 +1,23 @@ +package com.reown.sample.wallet.domain.model + +import com.reown.sample.wallet.R + +object NetworkUtils { + fun getNameByChainId(chainId: String): String { + return when (chainId) { + "eip155:10" -> "Optimism" + "eip155:8453" -> "Base" + "eip155:42161" -> "Arbitrum" + else -> chainId + } + } + + fun getIconByChainId(chainId: String): Int { + return when (chainId) { + "eip155:10" -> R.drawable.ic_optimism + "eip155:8453" -> R.drawable.base_network_logo + "eip155:42161" -> R.drawable.ic_arbitrum + else -> com.reown.sample.common.R.drawable.ic_walletconnect_circle_blue + } + } +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/Transaction.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/Transaction.kt new file mode 100644 index 000000000..d6ab22b9f --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/domain/model/Transaction.kt @@ -0,0 +1,257 @@ +package com.reown.sample.wallet.domain.model + +import com.reown.sample.wallet.BuildConfig +import com.reown.sample.wallet.blockchain.JsonRpcRequest +import com.reown.sample.wallet.blockchain.createBlockChainApiService +import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.walletkit.client.ChainAbstractionExperimentalApi +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import org.json.JSONArray +import org.web3j.crypto.Credentials +import org.web3j.crypto.RawTransaction +import org.web3j.crypto.TransactionEncoder +import org.web3j.tx.gas.DefaultGasProvider +import org.web3j.utils.Numeric +import org.web3j.utils.Numeric.toBigInt +import java.math.BigDecimal +import java.math.BigInteger + +object Transaction { + suspend fun send(sessionRequest: Wallet.Model.SessionRequest): String { + val transaction = getTransaction(sessionRequest) + val nonceResult = getNonce(transaction.chainId, transaction.from) + val signedTransaction = sign(transaction, nonceResult, DefaultGasProvider.GAS_LIMIT) + val txHash = sendRaw(transaction.chainId, signedTransaction) + return txHash + } + + @OptIn(ChainAbstractionExperimentalApi::class) + fun getTransaction(sessionRequest: Wallet.Model.SessionRequest): Wallet.Model.FeeEstimatedTransaction { + val fees = WalletKit.estimateFees(sessionRequest.chainId!!) + val requestParams = JSONArray(sessionRequest.request.params).getJSONObject(0) + val from = requestParams.getString("from") + val to = requestParams.getString("to") + val data = requestParams.getString("data") + val value = try { + requestParams.getString("value") + } catch (e: Exception) { + "0" + } + val nonce = try { + requestParams.getString("nonce") + } catch (e: Exception) { + "0" + } + val gas = try { + requestParams.getString("gas") + } catch (e: Exception) { + "0" + } + + return Wallet.Model.FeeEstimatedTransaction( + from = from, + to = to, + value = value, + input = data, + nonce = nonce, + gasLimit = gas, + chainId = sessionRequest.chainId!!, + maxFeePerGas = fees.maxFeePerGas, + maxPriorityFeePerGas = fees.maxPriorityFeePerGas + ) + } + + fun getInitialTransaction(sessionRequest: Wallet.Model.SessionRequest): Wallet.Model.InitialTransaction { + val requestParams = JSONArray(sessionRequest.request.params).getJSONObject(0) + val from = requestParams.getString("from") + val to = requestParams.getString("to") + val data = requestParams.getString("data") + val value = try { + requestParams.getString("value") + } catch (e: Exception) { + "0" + } + val gas = try { + requestParams.getString("gas") + } catch (e: Exception) { + "0" + } + + return Wallet.Model.InitialTransaction( + from = from, + to = to, + value = value, + chainId = sessionRequest.chainId!!, + input = data, + ) + } + + fun sign(transaction: Wallet.Model.FeeEstimatedTransaction, nonce: BigInteger? = null, gasLimit: BigInteger? = null): String { + val chainId = transaction.chainId.split(":")[1].toLong() + if (transaction.nonce.startsWith("0x")) { + transaction.nonce = hexToBigDecimal(transaction.nonce)?.toBigInteger().toString() + } + + if (transaction.gasLimit.startsWith("0x")) { + transaction.gasLimit = hexToBigDecimal(transaction.gasLimit)?.toBigInteger().toString() + } + if (transaction.value.startsWith("0x")) { + transaction.value = hexToBigDecimal(transaction.value)?.toBigInteger().toString() + } + if (transaction.maxFeePerGas.startsWith("0x")) { + transaction.maxFeePerGas = hexToBigDecimal(transaction.maxFeePerGas)?.toBigInteger().toString() + } + if (transaction.maxPriorityFeePerGas.startsWith("0x")) { + transaction.maxPriorityFeePerGas = hexToBigDecimal(transaction.maxPriorityFeePerGas)?.toBigInteger().toString() + } + + println("chainId: $chainId") + println("nonce: ${nonce ?: transaction.nonce.toBigInteger()}") + println("gas: ${gasLimit ?: transaction.gasLimit.toBigInteger()}") + println("value: ${transaction.value}") + println("maxFeePerGas: ${transaction.maxFeePerGas.toBigInteger()}") + println("maxPriorityFeePerGas: ${transaction.maxPriorityFeePerGas.toBigInteger()}") + println("//////////////////////////////////////") + + val rawTransaction = RawTransaction.createTransaction( + chainId, + nonce ?: transaction.nonce.toBigInteger(), + gasLimit ?: transaction.gasLimit.toBigInteger(), + transaction.to, + transaction.value.toBigInteger(), + transaction.input, + transaction.maxPriorityFeePerGas.toBigInteger(), + transaction.maxFeePerGas.toBigInteger(), + ) + + return Numeric.toHexString(TransactionEncoder.signMessage(rawTransaction, Credentials.create(EthAccountDelegate.privateKey))) + } + + suspend fun getNonce(chainId: String, from: String): BigInteger { + return coroutineScope { + val service = createBlockChainApiService(BuildConfig.PROJECT_ID, chainId) + val nonceRequest = JsonRpcRequest( + method = "eth_getTransactionCount", + params = listOf(from, "latest"), + id = generateId() + ) + + val nonceResult = async { service.sendJsonRpcRequest(nonceRequest) }.await() + if (nonceResult.error != null) { + throw Exception("Getting nonce failed: ${nonceResult.error.message}") + } else { + val nonceHex = nonceResult.result as String + hexToBigDecimal(nonceHex)?.toBigInteger() ?: throw Exception("Getting nonce failed") + } + } + } + + suspend fun getBalance(chainId: String, from: String): String { + return coroutineScope { + val service = createBlockChainApiService(BuildConfig.PROJECT_ID, chainId) + val nonceRequest = JsonRpcRequest( + method = "eth_getBalance", + params = listOf(from, "latest"), + id = generateId() + ) + + val nonceResult = async { service.sendJsonRpcRequest(nonceRequest) }.await() + if (nonceResult.error != null) { + throw Exception("Getting nonce failed: ${nonceResult.error.message}") + } else { + nonceResult.result as String + } + } + } + + suspend fun sendRaw(chainId: String, signedTx: String, txType: String = ""): String { + val service = createBlockChainApiService(BuildConfig.PROJECT_ID, chainId) + val request = JsonRpcRequest( + method = "eth_sendRawTransaction", + params = listOf(signedTx), + id = generateId() + ) + val resultTx = service.sendJsonRpcRequest(request) + + if (resultTx.error != null) { + throw Exception("$txType transaction failed: ${resultTx.error.message}") + } else { + return resultTx.result as String + } + } + + suspend fun getReceipt(chainId: String, txHash: String) { + withTimeout(30000) { + while (true) { + val service = createBlockChainApiService(BuildConfig.PROJECT_ID, chainId) + val nonceRequest = JsonRpcRequest( + method = "eth_getTransactionReceipt", + params = listOf(txHash), + id = generateId() + ) + + val receipt = async { service.sendJsonRpcRequest(nonceRequest) }.await() + when { + receipt.error != null -> throw Exception("Getting tx receipt failed: ${receipt.error.message}") + receipt.result == null -> delay(3000) + else -> { + println("receipt: $receipt") + break + } + } + } + } + } + + fun hexToBigDecimal(input: String): BigDecimal? { + val trimmedInput = input.trim() + var hex = trimmedInput + return if (hex.isEmpty()) { + null + } else try { + val isHex: Boolean = containsHexPrefix(hex) + if (isHex) { + hex = Numeric.cleanHexPrefix(trimmedInput) + } + BigInteger(hex, if (isHex) HEX else DEC).toBigDecimal() + } catch (ex: NullPointerException) { + null + } catch (ex: NumberFormatException) { + null + } + } + + fun hexToTokenAmount(hexValue: String, decimals: Int): BigDecimal? { + return try { + val cleanedHex = hexValue.removePrefix("0x") + val amountBigInt = cleanedHex.toBigInteger(16) + val divisor = BigDecimal.TEN.pow(decimals) + BigDecimal(amountBigInt).divide(divisor) + } catch (e: NumberFormatException) { + println("Invalid hexadecimal value: $hexValue") + null + } + } + + fun convertTokenAmount(value: BigInteger, decimals: Int): BigDecimal? { + return try { + val divisor = BigDecimal.TEN.pow(decimals) + BigDecimal(value).divide(divisor) + } catch (e: NumberFormatException) { + println("Invalid value: $value") + null + } + } + + private fun generateId(): Int = ("${(100..999).random()}").toInt() + + private fun containsHexPrefix(input: String): Boolean = input.startsWith("0x") + private const val HEX = 16 + private const val DEC = 10 +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/WalletKitActivity.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/WalletKitActivity.kt index a6f8a856d..309b65909 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/WalletKitActivity.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/WalletKitActivity.kt @@ -139,6 +139,7 @@ class WalletKitActivity : AppCompatActivity() { when (event) { is SignEvent.SessionProposal -> navigateWhenReady { navController.navigate(Route.SessionProposal.path) } is SignEvent.SessionAuthenticate -> navigateWhenReady { navController.navigate(Route.SessionAuthenticate.path) } + is SignEvent.Fulfilment -> navigateWhenReady { navController.navigate("${Route.ChainAbstraction.path}/${event.isError}")} is SignEvent.ExpiredRequest -> { if (navController.currentDestination?.route != Route.Connections.path) { navController.popBackStack(route = Route.Connections.path, inclusive = false) diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletEvent.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletEvent.kt index d0ed7c5fd..59334e942 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletEvent.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletEvent.kt @@ -8,6 +8,7 @@ interface SignEvent : Web3WalletEvent { object SessionProposal : SignEvent object SessionAuthenticate : SignEvent data class SessionRequest(val arrayOfArgs: ArrayList, val numOfArgs: Int) : SignEvent + data class Fulfilment(val isError: Boolean) : SignEvent object ExpiredRequest : SignEvent object Disconnect : SignEvent data class ConnectionState(val isAvailable: Boolean) : SignEvent diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletNavGraph.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletNavGraph.kt index 6e6bf3b75..524d2b718 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletNavGraph.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletNavGraph.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavDeepLink import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.dialog @@ -44,9 +43,11 @@ import com.reown.sample.wallet.ui.routes.composable_routes.settings.SettingsRout import com.reown.sample.wallet.ui.routes.dialog_routes.paste_uri.PasteUriRoute import com.reown.sample.wallet.ui.routes.dialog_routes.session_authenticate.SessionAuthenticateRoute import com.reown.sample.wallet.ui.routes.dialog_routes.session_proposal.SessionProposalRoute -import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.SessionRequestRoute +import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.chain_abstraction.ChainAbstractionRoute +import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request.SessionRequestRoute import com.reown.sample.wallet.ui.routes.dialog_routes.snackbar_message.SnackbarMessageRoute +@OptIn(ExperimentalAnimationApi::class) @SuppressLint("RestrictedApi") @ExperimentalMaterialNavigationApi @Composable @@ -127,6 +128,17 @@ fun Web3WalletNavGraph( ) { NotificationsScreenRoute(navController, it.arguments?.getString("topic")!!, inboxViewModel) } + dialog( + route = "${Route.ChainAbstraction.path}/{isError}", + arguments = listOf( + navArgument("isError") { + type = NavType.BoolType + nullable = false + }), + dialogProperties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ChainAbstractionRoute(navController, it.arguments?.getBoolean("isError")!!) + } composable(Route.Inbox.path) { InboxRoute(navController, inboxViewModel) } diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletViewModel.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletViewModel.kt index 372130102..31f1da622 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletViewModel.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/Web3WalletViewModel.kt @@ -8,7 +8,6 @@ import com.google.firebase.ktx.Firebase import com.reown.android.Core import com.reown.android.internal.common.exception.InvalidProjectIdException import com.reown.android.internal.common.exception.ProjectIdDoesNotExistException -import com.reown.sample.wallet.domain.ISSUER import com.reown.sample.wallet.domain.WCDelegate import com.reown.sample.wallet.ui.state.ConnectionState import com.reown.sample.wallet.ui.state.PairingEvent @@ -114,6 +113,9 @@ class Web3WalletViewModel : ViewModel() { } } + is Wallet.Model.PrepareSuccess.Available -> SignEvent.Fulfilment(isError = false) + is Wallet.Model.PrepareError -> SignEvent.Fulfilment(isError = true) + is Wallet.Model.SessionAuthenticate -> { _isLoadingFlow.value = false SignEvent.SessionAuthenticate diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/Buttons.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/Buttons.kt index 92cea5f57..c3e91d44f 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/Buttons.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/Buttons.kt @@ -1,23 +1,109 @@ package com.reown.sample.wallet.ui.common import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.reown.sample.wallet.ui.common.generated.ButtonWithLoader +import com.reown.sample.wallet.ui.common.generated.ButtonWithoutLoader +@Composable +fun Button(modifier: Modifier = Modifier, onClick: () -> Unit = {}, text: String, textColor: Color = Color(0xFF000000)) { + ButtonWithoutLoader( + buttonColor = Color(0x2A2A2A), + modifier = Modifier + .height(46.dp) + .clickable { onClick() }, + content = { + Text( + text = text, + style = TextStyle( + fontSize = 16.0.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + ), + modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically).wrapContentWidth(align = Alignment.CenterHorizontally) + ) + } + ) +} + +@Composable +fun ButtonsVertical( + allowButtonColor: Color, + modifier: Modifier = Modifier, + onCancel: () -> Unit = {}, + onConfirm: () -> Unit = {}, + isLoadingConfirm: Boolean, + isLoadingCancel: Boolean +) { + Column(modifier = modifier) { + ButtonWithLoader( + brush = Brush.linearGradient( + colors = listOf(Color(0xFF732BCC), Color(0xFF076CF1)), + start = Offset(0f, 0f), + end = Offset(1000f, 0f) + ), + buttonColor = Color(0xFFFFFFFF), + loaderColor = Color(0xFFFFFFFF), + modifier = Modifier + .padding(8.dp) + .height(60.dp) + .clickable { onConfirm() }, + isLoading = isLoadingConfirm, + content = { + Text( + text = "Confirm", + style = TextStyle( + fontSize = 20.0.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFFFFF), + ), + modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically) + ) + } + ) + ButtonWithLoader( + buttonColor = Color(0xFF363636), + loaderColor = Color(0xFFFFFFFF), + modifier = Modifier + .padding(8.dp) + .height(60.dp) + .clickable { onCancel() }, + isLoading = isLoadingCancel, + content = { + Text( + text = "Cancel", + style = TextStyle( + fontSize = 20.0.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFFFFF), + ), + modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically) + ) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + } +} + @Composable fun Buttons( allowButtonColor: Color, diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/InnerContent.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/InnerContent.kt index e29795669..276b44370 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/InnerContent.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/InnerContent.kt @@ -3,6 +3,7 @@ package com.reown.sample.wallet.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -21,6 +22,7 @@ fun InnerContent(content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() + .fillMaxHeight() .padding(horizontal = 5.dp) .clip(sectionShape) .border(width = 1.dp, color = Color(0xFF000000).copy(alpha = .1f), shape = sectionShape) diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/SemiTransparentDialog.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/SemiTransparentDialog.kt index 73a8ecd79..e240b3256 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/SemiTransparentDialog.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/SemiTransparentDialog.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import com.reown.sample.common.ui.themedColor @Composable -fun SemiTransparentDialog(backgroundColor: Color = themedColor(Color(0xFF242425), Color(0xFFFFFFFF)), content: @Composable () -> Unit) { +fun SemiTransparentDialog(backgroundColor: Color = themedColor(Color(0xFF2A2A2A), Color(0xFFFFFFFF)), content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/ButtonWithLoader.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/ButtonWithLoader.kt index ae0c5cc75..b2b33c0b2 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/ButtonWithLoader.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/ButtonWithLoader.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -24,13 +25,25 @@ fun ButtonWithLoader( loaderColor: Color, modifier: Modifier = Modifier, isLoading: Boolean, + brush: Brush? = null, content: @Composable () -> Unit ) { - ButtonTopLevel(buttonColor, modifier = modifier) { + ButtonTopLevel(buttonColor, brush, modifier = modifier) { Button(isLoading = isLoading, content = content, loaderColor = loaderColor) } } +@Composable +fun ButtonWithoutLoader( + buttonColor: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + ButtonTopLevel(buttonColor, modifier = modifier) { + Button(isLoading = false, content = content, loaderColor = Color(0xFF000000)) + } +} + @Composable fun Button(modifier: Modifier = Modifier, isLoading: Boolean, content: @Composable () -> Unit, loaderColor: Color) { AnimatedContent(targetState = isLoading, label = "Loading") { state -> @@ -52,23 +65,43 @@ fun Button(modifier: Modifier = Modifier, isLoading: Boolean, content: @Composab @Composable fun ButtonTopLevel( buttonColor: Color, + brush: Brush? = null, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .padding( - start = 8.0.dp, - top = 0.0.dp, - end = 8.0.dp, - bottom = 1.0.dp - ) - .clip(RoundedCornerShape(20.dp)) - .background(buttonColor) - .fillMaxWidth(1.0f) - .fillMaxHeight(1.0f) - ) { - content() + if (brush != null) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .padding( + start = 8.0.dp, + top = 0.0.dp, + end = 8.0.dp, + bottom = 1.0.dp + ) + .clip(RoundedCornerShape(20.dp)) + .background(brush) + .fillMaxWidth(1.0f) + .fillMaxHeight(1.0f) + ) { + content() + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .padding( + start = 8.0.dp, + top = 0.0.dp, + end = 8.0.dp, + bottom = 1.0.dp + ) + .clip(RoundedCornerShape(20.dp)) + .background(buttonColor) + .fillMaxWidth(1.0f) + .fillMaxHeight(1.0f) + ) { + content() + } } } diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/CancelButton.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/CancelButton.kt index 02617bf2d..4636eec82 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/CancelButton.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/common/generated/CancelButton.kt @@ -15,16 +15,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun CancelButton(modifier: Modifier = Modifier, backgroundColor: Color = Color(0xFFD6D6D6)) { +fun CancelButton(modifier: Modifier = Modifier, backgroundColor: Color = Color(0xFFD6D6D6), text: String = "Cancel") { CancelButtonTopLevel(modifier = modifier, backgroundColor = backgroundColor) { - Cancel() + Cancel(text = text) } } @Composable -fun Cancel(modifier: Modifier = Modifier) { +fun Cancel(modifier: Modifier = Modifier, text: String) { Text( - text = "Cancel", + text = text, style = TextStyle( fontSize = 20.0.sp, fontWeight = FontWeight.SemiBold, diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/Route.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/Route.kt index 591f9e0b6..de47815ba 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/Route.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/Route.kt @@ -8,6 +8,7 @@ sealed class Route(val path: String) { object SessionProposal : Route("session_proposal") object SessionRequest : Route("session_request") object SessionAuthenticate : Route("session_authenticate") + object ChainAbstraction : Route("chain_abstraction") object PasteUri : Route("paste_uri") object ScanUri : Route("scan_uri") diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/connection_details/ConnectionDetailsRoute.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/connection_details/ConnectionDetailsRoute.kt index 6ee14c488..694b0d96b 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/connection_details/ConnectionDetailsRoute.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/connection_details/ConnectionDetailsRoute.kt @@ -299,9 +299,7 @@ fun ChainPermissions(account: String, accountsToSessions: Map, onLogoutClicked: () -> Unit, onSettingClicked: (String) -> Unit, ) { + Divider() Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { WCTopAppBar(titleText = "Settings") Divider() - LazyColumn() { + FeaturesSection() + Divider() + LazyColumn { itemsIndexed(sections) { index, section -> when (section) { - is Section.SettingsSection -> { - SettingsSection(section.title, section.items, onSettingClicked) - } - - is Section.LogoutSection -> { - LogoutSection(onLogoutClicked) - } + is Section.SettingsSection -> SettingsSection(section.title, section.items, onSettingClicked) + is Section.LogoutSection -> LogoutSection(onLogoutClicked) } if (index != sections.lastIndex) Divider() } @@ -104,6 +108,38 @@ private fun SettingsScreen( } } +@Composable +private fun FeaturesSection() { + val isSafeEnabled by SmartAccountEnabler.isSmartAccountEnabled.collectAsState() + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalAlignment = Alignment.Start, + ) { + Text(text = "Features", style = TextStyle(fontSize = 15.sp), fontWeight = FontWeight(700)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Safe Smart Account", style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight(400), + color = MaterialTheme.colors.onBackground.copy(0.75f) + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Switch( + checked = isSafeEnabled, + onCheckedChange = { isChecked -> SmartAccountEnabler.enableSmartAccount(isChecked) }, + colors = SwitchDefaults.colors(checkedThumbColor = Color.Green) + ) + } + } +} + @Composable fun SettingsSection(title: String, items: List, onSettingClicked: (String) -> Unit) { Column( @@ -120,7 +156,6 @@ fun SettingsSection(title: String, items: List, onSettingClicked: (String) } } - @Composable fun SettingCopyableItem(key: String, value: String, onSettingClicked: (String) -> Unit) { val shape = RoundedCornerShape(12.dp) diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/settings/SettingsViewModel.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/settings/SettingsViewModel.kt index 365ed1491..3628ea372 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/settings/SettingsViewModel.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/composable_routes/settings/SettingsViewModel.kt @@ -1,10 +1,18 @@ +@file:OptIn(SmartAccountExperimentalApi::class) + package com.reown.sample.wallet.ui.routes.composable_routes.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import com.google.firebase.messaging.FirebaseMessaging import com.reown.android.CoreClient import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.sample.wallet.domain.recordError +import com.reown.walletkit.client.SmartAccountExperimentalApi +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -14,6 +22,18 @@ class SettingsViewModel : ViewModel() { val privateKey = EthAccountDelegate.privateKey val clientId = CoreClient.Echo.clientId + fun getSmartAccount(): String { + val params = Wallet.Params.GetSmartAccountAddress(Wallet.Params.Account(address = EthAccountDelegate.sepoliaAddress)) + val smartAccountAddress = try { + WalletKit.getSmartAccount(params) + } catch (e: Exception) { + println("Getting SA account error: ${e.message}") + recordError(e) + "error" + } + return "eip155:11155111:$smartAccountAddress" + } + private val _deviceToken = MutableStateFlow("") val deviceToken = _deviceToken.asStateFlow() diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalUI.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalUI.kt index 6ae3a9208..2a2d4db0b 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalUI.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalUI.kt @@ -1,10 +1,15 @@ package com.reown.sample.wallet.ui.routes.dialog_routes.session_proposal +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import com.reown.sample.wallet.domain.ACCOUNTS_1_EIP155_ADDRESS import com.reown.sample.wallet.domain.ACCOUNTS_2_EIP155_ADDRESS +import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.sample.wallet.domain.recordError import com.reown.sample.wallet.ui.common.peer.PeerContextUI import com.reown.sample.wallet.ui.common.peer.PeerUI import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit data class SessionProposalUI( val peerUI: PeerUI, @@ -20,6 +25,50 @@ data class WalletMetaData( val namespaces: Map, ) +val smartAccountWalletMetadata = + WalletMetaData( + peerUI = PeerUI( + peerIcon = "https://raw.githubusercontent.com/WalletConnect/walletconnect-assets/master/Icon/Gradient/Icon.png", + peerName = "Kotlin.Wallet", + peerUri = "kotlin.wallet.app", + peerDescription = "Kotlin Wallet Description" + ), + namespaces = mapOf( + "eip155" to Wallet.Model.Namespace.Session( + chains = listOf("eip155:11155111"), + methods = listOf( + "eth_sendTransaction", + "personal_sign", + "eth_accounts", + "eth_requestAccounts", + "eth_call", + "eth_getBalance", + "eth_sendRawTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "eth_signTypedData_v4", + "wallet_switchEthereumChain", + "wallet_addEthereumChain", + "wallet_sendCalls", + "wallet_getCallsStatus" + ), + events = listOf("chainChanged", "accountsChanged", "connect", "disconnect"), + accounts = listOf( + "eip155:11155111:${ + try { + WalletKit.getSmartAccount(Wallet.Params.GetSmartAccountAddress(Wallet.Params.Account(EthAccountDelegate.sepoliaAddress))) + } catch (e: Exception) { + println("Getting SA account error: ${e.message}") + recordError(e) + "" + } + }" + ) + ) + ) + ) + val walletMetaData = WalletMetaData( peerUI = PeerUI( peerIcon = "https://raw.githubusercontent.com/WalletConnect/walletconnect-assets/master/Icon/Gradient/Icon.png", @@ -29,7 +78,7 @@ val walletMetaData = WalletMetaData( ), namespaces = mapOf( "eip155" to Wallet.Model.Namespace.Session( - chains = listOf("eip155:1", "eip155:137", "eip155:56"), + chains = listOf("eip155:1", "eip155:137", "eip155:56", "eip155:42161", "eip155:8453", "eip155:10"), methods = listOf( "eth_sendTransaction", "personal_sign", @@ -43,16 +92,18 @@ val walletMetaData = WalletMetaData( "eth_signTypedData", "eth_signTypedData_v4", "wallet_switchEthereumChain", - "wallet_addEthereumChain" + "wallet_addEthereumChain", + "wallet_sendCalls", + "wallet_getCallsStatus" ), events = listOf("chainChanged", "accountsChanged", "connect", "disconnect"), accounts = listOf( "eip155:1:$ACCOUNTS_1_EIP155_ADDRESS", - "eip155:1:$ACCOUNTS_2_EIP155_ADDRESS", "eip155:137:$ACCOUNTS_1_EIP155_ADDRESS", - "eip155:137:$ACCOUNTS_2_EIP155_ADDRESS", "eip155:56:$ACCOUNTS_1_EIP155_ADDRESS", - "eip155:56:$ACCOUNTS_2_EIP155_ADDRESS" + "eip155:42161:$ACCOUNTS_1_EIP155_ADDRESS", + "eip155:8453:$ACCOUNTS_1_EIP155_ADDRESS", + "eip155:10:$ACCOUNTS_1_EIP155_ADDRESS", ) ), "cosmos" to Wallet.Model.Namespace.Session( diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalViewModel.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalViewModel.kt index 70525b9a6..8a19c1f5d 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalViewModel.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_proposal/SessionProposalViewModel.kt @@ -3,6 +3,9 @@ package com.reown.sample.wallet.ui.routes.dialog_routes.session_proposal import androidx.lifecycle.ViewModel import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase +import com.reown.sample.wallet.domain.ACCOUNTS_1_EIP155_ADDRESS +import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.sample.wallet.domain.SmartAccountEnabler import com.reown.sample.wallet.domain.WCDelegate import com.reown.sample.wallet.ui.common.peer.PeerUI import com.reown.sample.wallet.ui.common.peer.toPeerUI @@ -17,9 +20,8 @@ class SessionProposalViewModel : ViewModel() { if (proposal != null) { try { Timber.d("Approving session proposal: $proposalPublicKey") - val sessionNamespaces = WalletKit.generateApprovedNamespaces(sessionProposal = proposal, supportedNamespaces = walletMetaData.namespaces) - val approveProposal = Wallet.Params.SessionApprove(proposerPublicKey = proposal.proposerPublicKey, namespaces = sessionNamespaces) - + val (sessionNamespaces, sessionProperties) = getNamespacesAndProperties(proposal) + val approveProposal = Wallet.Params.SessionApprove(proposerPublicKey = proposal.proposerPublicKey, namespaces = sessionNamespaces, properties = sessionProperties) WalletKit.approveSession(approveProposal, onError = { error -> Firebase.crashlytics.recordException(error.throwable) @@ -40,6 +42,27 @@ class SessionProposalViewModel : ViewModel() { } } + private fun getNamespacesAndProperties(proposal: Wallet.Model.SessionProposal): Pair, Map> { + return if (SmartAccountEnabler.isSmartAccountEnabled.value) { + val sessionNamespaces = + WalletKit.generateApprovedNamespaces(sessionProposal = proposal, supportedNamespaces = smartAccountWalletMetadata.namespaces) + val ownerAccount = Wallet.Params.Account(EthAccountDelegate.sepoliaAddress) + val smartAccountAddress = try { + WalletKit.getSmartAccount(Wallet.Params.GetSmartAccountAddress(ownerAccount)) + } catch (e: Exception) { + Firebase.crashlytics.recordException(e) + "" + } + + val capability = "{\"$smartAccountAddress\":{\"0xaa36a7\":{\"atomicBatch\":{\"supported\":true}}}}" + val sessionProperties = mapOf("bundler_name" to "pimlico", "capabilities" to capability) + Pair(sessionNamespaces, sessionProperties) + } else { + val sessionNamespaces = WalletKit.generateApprovedNamespaces(sessionProposal = proposal, supportedNamespaces = walletMetaData.namespaces) + Pair(sessionNamespaces, mapOf()) + } + } + fun reject(proposalPublicKey: String, onSuccess: (String) -> Unit = {}, onError: (Throwable) -> Unit = {}) { val proposal = WalletKit.getSessionProposals().find { it.proposerPublicKey == proposalPublicKey } if (proposal != null) { diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionRoute.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionRoute.kt new file mode 100644 index 000000000..01feb4d4f --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionRoute.kt @@ -0,0 +1,869 @@ +package com.reown.sample.wallet.ui.routes.dialog_routes.session_request.chain_abstraction + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import com.reown.android.internal.common.exception.NoConnectivityException +import com.reown.sample.common.sendResponseDeepLink +import com.reown.sample.common.ui.theme.verified_color +import com.reown.sample.common.ui.themedColor +import com.reown.sample.wallet.R +import com.reown.sample.wallet.domain.WCDelegate +import com.reown.sample.wallet.domain.getErrorMessage +import com.reown.sample.wallet.domain.model.NetworkUtils +import com.reown.sample.wallet.domain.model.Transaction +import com.reown.sample.wallet.ui.common.Buttons +import com.reown.sample.wallet.ui.common.ButtonsVertical +import com.reown.sample.wallet.ui.common.InnerContent +import com.reown.sample.wallet.ui.common.SemiTransparentDialog +import com.reown.sample.wallet.ui.common.generated.ButtonWithLoader +import com.reown.sample.wallet.ui.common.peer.Peer +import com.reown.sample.wallet.ui.common.peer.PeerUI +import com.reown.sample.wallet.ui.common.peer.getColor +import com.reown.sample.wallet.ui.routes.Route +import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request.SessionRequestUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("RestrictedApi") +@Composable +fun ChainAbstractionRoute(navController: NavHostController, isError: Boolean, chainAbstractionViewModel: ChainAbstractionViewModel = viewModel()) { + val sessionRequestUI = chainAbstractionViewModel.sessionRequestUI + val composableScope = rememberCoroutineScope() + val context = LocalContext.current + var isConfirmLoading by remember { mutableStateOf(false) } + var isCancelLoading by remember { mutableStateOf(false) } + var shouldShowErrorDialog by remember { mutableStateOf(false) } + var shouldShowSuccessDialog by remember { mutableStateOf(false) } + + when { + shouldShowSuccessDialog -> SuccessDialog(navController, chainAbstractionViewModel) + shouldShowErrorDialog -> ErrorDialog(navController, chainAbstractionViewModel) + else -> when (sessionRequestUI) { + is SessionRequestUI.Content -> { + val allowButtonColor = getColor(sessionRequestUI.peerContextUI) + WCDelegate.currentId = sessionRequestUI.requestId + + SemiTransparentDialog { + Spacer(modifier = Modifier.height(24.dp)) + Peer(peerUI = sessionRequestUI.peerUI, "Review transaction", sessionRequestUI.peerContextUI) + Spacer(modifier = Modifier.height(16.dp)) + Request(chainAbstractionViewModel, isError) + Spacer(modifier = Modifier.height(8.dp)) + if (isError) { + ButtonWithLoader( + buttonColor = Color(0xFF363636), + loaderColor = Color(0xFFFFFFFF), + modifier = Modifier + .padding(8.dp) + .height(60.dp) + .clickable { navController.popBackStack() }, + isLoading = false, + content = { + Text( + text = "Back to App", + style = TextStyle( + fontSize = 20.0.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFFFFF), + ), + modifier = Modifier.wrapContentHeight(align = Alignment.CenterVertically) + ) + } + ) + } else { + ButtonsVertical( + allowButtonColor, + onConfirm = { + confirmRequest( + sessionRequestUI, + navController, + chainAbstractionViewModel, + composableScope, + context, + toggleConfirmLoader = { isConfirmLoading = it }, + onSuccess = { hash -> + chainAbstractionViewModel.txHash = hash + shouldShowSuccessDialog = true + }, + onError = { message: String -> + chainAbstractionViewModel.errorMessage = message + shouldShowErrorDialog = true + } + ) + }, + onCancel = { cancelRequest(sessionRequestUI, navController, chainAbstractionViewModel, composableScope, context) { isCancelLoading = it } }, + isLoadingConfirm = isConfirmLoading, + isLoadingCancel = isCancelLoading + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + + SessionRequestUI.Initial -> { + SemiTransparentDialog { + Spacer(modifier = Modifier.height(24.dp)) + Peer(peerUI = PeerUI.Empty, null) + Spacer(modifier = Modifier.height(200.dp)) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(strokeWidth = 8.dp, modifier = Modifier.size(100.dp), color = Color(0xFFB8F53D)) + } + Spacer(modifier = Modifier.height(200.dp)) + Buttons( + verified_color, + modifier = Modifier + .padding(vertical = 8.dp) + .blur(4.dp) + .padding(vertical = 8.dp), + isLoadingConfirm = isConfirmLoading, + isLoadingCancel = isCancelLoading + ) + } + + } + } + } +} + +@Composable +fun ErrorDialog( + navController: NavHostController, + chainAbstractionViewModel: ChainAbstractionViewModel +) { + SemiTransparentDialog { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Something went wrong", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(500), + color = Color(0xFFFFFFFF), + textAlign = TextAlign.Center, + ) + ) + Spacer(modifier = Modifier.height(32.dp)) + Image(modifier = Modifier.size(64.dp), painter = painterResource(R.drawable.ic_ca_error), contentDescription = null) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.padding(8.dp), + text = "${chainAbstractionViewModel.errorMessage}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + textAlign = TextAlign.Center, + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(10.dp) + .clip(shape = RoundedCornerShape(25.dp)) + .fillMaxWidth() + .background(themedColor(darkColor = Color(0xFF252525), lightColor = Color(0xFF505059).copy(.1f))) + .verticalScroll(rememberScrollState()) + ) { + InnerContent { + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Paying", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End) { + Text( + text = chainAbstractionViewModel.getTransferAmount(), + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 3.dp)) { + Text( + text = "on ${NetworkUtils.getNameByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")}", + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Image( + modifier = Modifier.size(12.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")), + contentDescription = "image description" + ) + } + } + } + WCDelegate.fulfilmentAvailable?.funding?.forEach { funding -> + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bridging", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End) { + Text( + text = "${Transaction.hexToTokenAmount(funding.amount, 6)?.toPlainString()}${funding.symbol}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 3.dp)) { + Text( + text = "from ${NetworkUtils.getNameByChainId(funding.chainId)}", + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Image( + modifier = Modifier.size(12.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(funding.chainId)), + contentDescription = "image description" + ) + } + } + } + } + } + } + ButtonWithLoader( + buttonColor = Color(0xFF363636), + loaderColor = Color(0xFFFFFFFF), + modifier = Modifier + .padding(8.dp) + .height(60.dp) + .clickable { navController.popBackStack() }, + isLoading = false, + content = { + Text( + text = "Back to App", + style = TextStyle( + fontSize = 20.0.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFFFFF), + ), + modifier = Modifier.wrapContentHeight(align = Alignment.CenterVertically) + ) + } + ) + } +} + +@Composable +fun SuccessDialog( + navController: NavHostController, + chainAbstractionViewModel: ChainAbstractionViewModel +) { + SemiTransparentDialog { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Transaction Completed", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(500), + color = Color(0xFFFFFFFF), + textAlign = TextAlign.Center, + ) + ) + Spacer(modifier = Modifier.height(32.dp)) + Image(modifier = Modifier.size(64.dp), painter = painterResource(R.drawable.ic_frame), contentDescription = null) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.padding(8.dp), + text = "You successfully send ${WCDelegate.fulfilmentAvailable?.initialTransactionMetadata?.symbol ?: ""}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + textAlign = TextAlign.Center, + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(10.dp) + .clip(shape = RoundedCornerShape(25.dp)) + .fillMaxWidth() + .background(themedColor(darkColor = Color(0xFF252525), lightColor = Color(0xFF505059).copy(.1f))) + .verticalScroll(rememberScrollState()) + ) { + InnerContent { + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Paying", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End) { + Text( + text = chainAbstractionViewModel.getTransferAmount(), + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 3.dp)) { + Text( + text = "on ${NetworkUtils.getNameByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")}", + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Image( + modifier = Modifier.size(12.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")), + contentDescription = "image description" + ) + } + } + } + WCDelegate.fulfilmentAvailable?.funding?.forEach { funding -> + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bridging", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End) { + Text( + text = "${Transaction.hexToTokenAmount(funding.amount, 6)?.toPlainString()}${funding.symbol}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 3.dp)) { + Text( + text = "from ${NetworkUtils.getNameByChainId(funding.chainId)}", + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Image( + modifier = Modifier.size(12.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(funding.chainId)), + contentDescription = "image description" + ) + } + } + } + } + } + } + ButtonWithLoader( + buttonColor = Color(0xFF363636), + loaderColor = Color(0xFFFFFFFF), + modifier = Modifier + .padding(8.dp) + .height(60.dp) + .clickable { navController.popBackStack() }, + isLoading = false, + content = { + Text( + text = "Back to App", + style = TextStyle( + fontSize = 20.0.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFFFFF), + ), + modifier = Modifier.wrapContentHeight(align = Alignment.CenterVertically) + ) + } + ) + } +} + +private fun cancelRequest( + sessionRequestUI: SessionRequestUI.Content, + navController: NavHostController, + chainAbstractionViewModel: ChainAbstractionViewModel, + composableScope: CoroutineScope, + context: Context, + toggleCancelLoader: (Boolean) -> Unit +) { + toggleCancelLoader(true) + if (sessionRequestUI.peerUI.linkMode) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + try { + chainAbstractionViewModel.reject( + onSuccess = { uri -> + toggleCancelLoader(false) + composableScope.launch(Dispatchers.Main) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + if (uri != null && uri.toString().isNotEmpty()) { + context.sendResponseDeepLink(uri) + } else { + composableScope.launch(Dispatchers.Main) { + Toast.makeText(context, "Go back to your browser", Toast.LENGTH_SHORT).show() + } + } + }, + onError = { error -> + toggleCancelLoader(false) + showError(navController, error, composableScope, context) + }) + } catch (e: Throwable) { + showError(navController, e, composableScope, context) + } +} + +private fun confirmRequest( + sessionRequestUI: SessionRequestUI.Content, + navController: NavHostController, + chainAbstractionViewModel: ChainAbstractionViewModel, + composableScope: CoroutineScope, + context: Context, + toggleConfirmLoader: (Boolean) -> Unit, + onSuccess: (hash: String) -> Unit, + onError: (message: String) -> Unit +) { + toggleConfirmLoader(true) + if (sessionRequestUI.peerUI.linkMode) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + try { + chainAbstractionViewModel.approve( + onSuccess = { result -> + toggleConfirmLoader(false) + if (result.redirect != null && result.redirect.toString().isNotEmpty()) { + context.sendResponseDeepLink(result.redirect) + } else { + onSuccess(result.hash) + } + }, + onError = { error -> + toggleConfirmLoader(false) + handleError(error, composableScope, context, onError) + }) + + } catch (e: Throwable) { + handleError(e, composableScope, context, onError) + } +} + +private fun handleError(error: Throwable, composableScope: CoroutineScope, context: Context, onError: (message: String) -> Unit) { + if (error is NoConnectivityException) { + composableScope.launch(Dispatchers.Main) { + Toast.makeText(context, error.message ?: "Session request error, please check your Internet connection", Toast.LENGTH_SHORT).show() + } + } else { + onError(error.message ?: "Session request error, please check your Internet connection") + } +} + +private fun showError(navController: NavHostController, throwable: Throwable?, coroutineScope: CoroutineScope, context: Context) { + coroutineScope.launch(Dispatchers.Main) { + if (throwable !is NoConnectivityException) { + navController.popBackStack() + } + Toast.makeText(context, throwable?.message ?: "Session request error, please check your Internet connection", Toast.LENGTH_SHORT).show() + } +} + +@Composable +fun Request(viewModel: ChainAbstractionViewModel, isError: Boolean) { + Column(modifier = Modifier.height(450.dp)) { + Column( + modifier = Modifier + .padding(10.dp) + .clip(shape = RoundedCornerShape(25.dp)) + .fillMaxWidth() + .background(themedColor(darkColor = Color(0xFF252525), lightColor = Color(0xFF505059).copy(.1f))) + .verticalScroll(rememberScrollState()) + ) { + if (!isError) { + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Paying", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Text( + text = viewModel.getTransferAmount(), + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + } + } + //Content + InnerContent { + Text( + modifier = Modifier.padding(vertical = 10.dp, horizontal = 13.dp), + text = "Source of funds", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(24.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")), + contentDescription = "Network" + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Balance", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + } + Column { + Text( + text = Transaction.hexToTokenAmount(viewModel.getERC20Balance(), 6)?.toPlainString() ?: "-.--", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + if (isError) { + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = getErrorMessage(), + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(500), + color = Color(0xFFDF4A34), + textAlign = TextAlign.Right, + ) + ) + } + } + + } + + if (!isError) { + WCDelegate.fulfilmentAvailable?.funding?.forEach { funding -> + Row( + modifier = Modifier.padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 38.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_bridge), + contentDescription = "Bridge" + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = "Bridging", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + } + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.End) { + Text( + text = "${Transaction.hexToTokenAmount(funding.amount, 6)?.toPlainString()}${funding.symbol}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 3.dp)) { + Text( + text = "from ${NetworkUtils.getNameByChainId(funding.chainId)}", + style = TextStyle( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Image( + modifier = Modifier.size(12.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(funding.chainId)), + contentDescription = "image description" + ) + } + } + } + Spacer(modifier = Modifier.height(5.dp)) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + if (!isError) { + Column( + modifier = Modifier + .padding(10.dp) + .clip(shape = RoundedCornerShape(25.dp)) + .fillMaxWidth() + .background(themedColor(darkColor = Color(0xFF252525), lightColor = Color(0xFF505059).copy(.1f))) + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 18.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Network", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(18.dp).clip(CircleShape), + painter = painterResource(id = NetworkUtils.getIconByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: "")), + contentDescription = "Network", + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = NetworkUtils.getNameByChainId(WCDelegate.fulfilmentAvailable?.initialTransaction?.chainId ?: ""), + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + } + } + + InnerContent { + Row( + modifier = Modifier.padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Estimated Fees", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Text( + text = "${WCDelegate.transactionsDetails?.localTotal?.formattedAlt} ${WCDelegate.transactionsDetails?.localTotal?.symbol}", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 18.sp, + fontWeight = FontWeight(400), + color = Color(0xFFFFFFFF), + ) + ) + } + + Row( + modifier = Modifier.padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bridge", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Text( + text = WCDelegate.transactionsDetails?.localBridgeTotal?.formattedAlt ?: "-.--", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + } + Spacer(modifier = Modifier.height(5.dp)) + Row( + modifier = Modifier.padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Purchase", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + Text( + text = "${WCDelegate.transactionsDetails?.initialDetails?.transactionFee?.localFee?.formattedAlt} ${WCDelegate.transactionsDetails?.initialDetails?.transactionFee?.localFee?.symbol}", + style = TextStyle( + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight(400), + color = Color(0xFF9A9A9A), + ) + ) + } + Spacer(modifier = Modifier.height(5.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + Spacer(modifier = Modifier.height(5.dp)) + } +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionViewModel.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionViewModel.kt new file mode 100644 index 000000000..808a493f6 --- /dev/null +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/chain_abstraction/ChainAbstractionViewModel.kt @@ -0,0 +1,248 @@ +package com.reown.sample.wallet.ui.routes.dialog_routes.session_request.chain_abstraction + +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.reown.android.internal.common.exception.NoConnectivityException +import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.sample.wallet.domain.Signer +import com.reown.sample.wallet.domain.WCDelegate +import com.reown.sample.wallet.domain.clearSessionRequest +import com.reown.sample.wallet.domain.status +import com.reown.sample.wallet.domain.getUSDCContractAddress +import com.reown.sample.wallet.domain.model.Transaction +import com.reown.sample.wallet.domain.recordError +import com.reown.sample.wallet.domain.respondWithError +import com.reown.sample.wallet.ui.common.peer.PeerUI +import com.reown.sample.wallet.ui.common.peer.toPeerUI +import com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request.SessionRequestUI +import com.reown.walletkit.client.ChainAbstractionExperimentalApi +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.web3j.utils.Numeric +import java.math.BigDecimal +import java.math.RoundingMode + +data class TxSuccess( + val redirect: Uri?, + val hash: String +) + +class ChainAbstractionViewModel : ViewModel() { + var txHash: String? = null + var errorMessage: String? = null + var sessionRequestUI: SessionRequestUI = generateSessionRequestUI() + + @OptIn(ChainAbstractionExperimentalApi::class) + fun getERC20Balance(): String { + val initialTransaction = WCDelegate.sessionRequestEvent?.first + val tokenAddress = getUSDCContractAddress(initialTransaction?.chainId ?: "") //todo: replace with init tx metadata + return try { + WalletKit.getERC20Balance(initialTransaction?.chainId ?: "", tokenAddress, EthAccountDelegate.account ?: "") + } catch (e: Exception) { + println("getERC20Balance error: $e") + recordError(e) + "" + } + } + + fun getTransferAmount(): String { + return "${ + Transaction.hexToTokenAmount( + WCDelegate.fulfilmentAvailable?.initialTransactionMetadata?.amount ?: "", + WCDelegate.fulfilmentAvailable?.initialTransactionMetadata?.decimals ?: 6 + )?.toPlainString() ?: "-.--" + } ${WCDelegate.fulfilmentAvailable?.initialTransactionMetadata?.symbol}" + } + + fun approve(onSuccess: (TxSuccess) -> Unit = {}, onError: (Throwable) -> Unit = {}) { + try { + val sessionRequest = sessionRequestUI as? SessionRequestUI.Content + if (sessionRequest != null) { + val signedTransactions = mutableListOf>() + val txHashesChannel = Channel>() + //sign fulfilment txs + WCDelegate.transactionsDetails?.fulfilmentDetails?.forEach { fulfilment -> + val signedTransaction = Transaction.sign(fulfilment.transaction) + signedTransactions.add(Pair(fulfilment.transaction.chainId, signedTransaction)) + } + + //execute fulfilment txs + signedTransactions.forEach { (chainId, signedTx) -> + viewModelScope.launch { + try { + println("Send raw: $signedTx") + val txHash = Transaction.sendRaw(chainId, signedTx, "Route") + println("Receive hash: $txHash") + txHashesChannel.send(Pair(chainId, txHash)) + } catch (e: Exception) { + println("Route broadcast tx error: $e") + recordError(e) + respondWithError(e.message ?: "Route TX broadcast error", WCDelegate.sessionRequestEvent?.first) + return@launch onError(e) + } + } + } + + //awaits receipts + viewModelScope.launch { + repeat(signedTransactions.size) { + val (chainId, txHash) = txHashesChannel.receive() + println("Receive tx hash: $txHash") + + try { + Transaction.getReceipt(chainId, txHash) + } catch (e: Exception) { + println("Route execution tx error: $e") + recordError(e) + respondWithError(e.message ?: "Route TX execution error", WCDelegate.sessionRequestEvent?.first) + return@launch onError(e) + } + } + txHashesChannel.close() + + + //check fulfilment status + println("Fulfilment status check") + val fulfilmentResult = async { status() }.await() + fulfilmentResult.fold( + onSuccess = { + when (it) { + is Wallet.Model.Status.Error -> { + println("Fulfilment error: ${it.reason}") + onError(Throwable(it.reason)) + } + + is Wallet.Model.Status.Completed -> { + println("Fulfilment completed") + //if status completed, execute init tx + with(WCDelegate.transactionsDetails!!.initialDetails.transaction) { + try { + val nonceResult = Transaction.getNonce(chainId, from) + println("Original TX") + val signedTx = Transaction.sign(this, nonceResult) + val resultTx = Transaction.sendRaw(chainId, signedTx, "Original") + val receipt = Transaction.getReceipt(chainId, resultTx) + println("Original TX receipt: $receipt") + val response = Wallet.Params.SessionRequestResponse( + sessionTopic = sessionRequest.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult(sessionRequest.requestId, resultTx) + ) + val redirect = WalletKit.getActiveSessionByTopic(sessionRequest.topic)?.redirect?.toUri() + WalletKit.respondSessionRequest(response, + onSuccess = { + clearSessionRequest() + onSuccess(TxSuccess(redirect, resultTx)) + }, + onError = { error -> + recordError(error.throwable) + if (error.throwable !is NoConnectivityException) { + clearSessionRequest() + } + onError(error.throwable) + }) + + } catch (e: Exception) { + recordError(e) + respondWithError(e.message ?: "Init TX execution error", WCDelegate.sessionRequestEvent?.first) + return@launch onError(e) + } + } + } + } + }, + onFailure = { + println("Fulfilment error: $it") + recordError(it) + onError(Throwable("Fulfilment status error: $it")) + } + ) + } + } + } catch (e: Exception) { + println("Error: $e") + recordError(e) + reject(message = e.message ?: "Undefined error, please check your Internet connection") + clearSessionRequest() + onError(Throwable(e.message ?: "Undefined error, please check your Internet connection")) + } + } + + fun reject(onSuccess: (Uri?) -> Unit = {}, onError: (Throwable) -> Unit = {}, message: String = "User rejected the request") { + try { + val sessionRequest = sessionRequestUI as? SessionRequestUI.Content + if (sessionRequest != null) { + val result = Wallet.Params.SessionRequestResponse( + sessionTopic = sessionRequest.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = sessionRequest.requestId, + code = 500, + message = message + ) + ) + val redirect = WalletKit.getActiveSessionByTopic(sessionRequest.topic)?.redirect?.toUri() + WalletKit.respondSessionRequest(result, + onSuccess = { + clearSessionRequest() + onSuccess(redirect) + }, + onError = { error -> + Firebase.crashlytics.recordException(error.throwable) + if (error.throwable !is NoConnectivityException) { + clearSessionRequest() + } + onError(error.throwable) + }) + } else { + onError(Throwable("Reject - Cannot find session request")) + } + } catch (e: Exception) { + Firebase.crashlytics.recordException(e) + clearSessionRequest() + onError(Throwable(e.message ?: "Undefined error, please check your Internet connection")) + } + } + + private fun extractMessageParamFromPersonalSign(input: String): String { + val jsonArray = JSONArray(input) + return if (jsonArray.length() > 0) { + if (jsonArray.getString(0).startsWith("0x")) { + String(Numeric.hexStringToByteArray(jsonArray.getString(0))) + } else { + jsonArray.getString(0) + } + } else { + throw IllegalArgumentException() + } + } + + private fun generateSessionRequestUI(): SessionRequestUI { + return if (WCDelegate.sessionRequestEvent != null) { + val (sessionRequest, context) = WCDelegate.sessionRequestEvent!! + SessionRequestUI.Content( + peerUI = PeerUI( + peerName = sessionRequest.peerMetaData?.name ?: "", + peerIcon = sessionRequest.peerMetaData?.icons?.firstOrNull() ?: "", + peerUri = sessionRequest.peerMetaData?.url ?: "", + peerDescription = sessionRequest.peerMetaData?.description ?: "", + linkMode = sessionRequest.peerMetaData?.linkMode ?: false + ), + topic = sessionRequest.topic, + requestId = sessionRequest.request.id, + param = if (sessionRequest.request.method == Signer.PERSONAL_SIGN) extractMessageParamFromPersonalSign(sessionRequest.request.params) else sessionRequest.request.params, + chain = sessionRequest.chainId, + method = sessionRequest.request.method, + peerContextUI = context.toPeerUI() + ) + } else { + SessionRequestUI.Initial + } + } +} \ No newline at end of file diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestRoute.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestRoute.kt similarity index 69% rename from sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestRoute.kt rename to sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestRoute.kt index 55d1bb1d8..9fcae79ee 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestRoute.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestRoute.kt @@ -1,4 +1,4 @@ -package com.reown.sample.wallet.ui.routes.dialog_routes.session_request +package com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request import android.annotation.SuppressLint import android.content.Context @@ -61,7 +61,6 @@ fun SessionRequestRoutePreview() { } } - @SuppressLint("RestrictedApi") @Composable fun SessionRequestRoute(navController: NavHostController, sessionRequestViewModel: SessionRequestViewModel = viewModel()) { @@ -74,7 +73,6 @@ fun SessionRequestRoute(navController: NavHostController, sessionRequestViewMode is SessionRequestUI.Content -> { val allowButtonColor = getColor(sessionRequestUI.peerContextUI) currentId = sessionRequestUI.requestId - SemiTransparentDialog { Spacer(modifier = Modifier.height(24.dp)) Peer(peerUI = sessionRequestUI.peerUI, "sends a request", sessionRequestUI.peerContextUI) @@ -83,63 +81,8 @@ fun SessionRequestRoute(navController: NavHostController, sessionRequestViewMode Spacer(modifier = Modifier.height(16.dp)) Buttons( allowButtonColor, - onConfirm = { - isConfirmLoading = true - if (sessionRequestUI.peerUI.linkMode) { - navController.popBackStack(route = Route.Connections.path, inclusive = false) - } - try { - sessionRequestViewModel.approve( - onSuccess = { uri -> - isConfirmLoading = false - composableScope.launch(Dispatchers.Main) { - navController.popBackStack(route = Route.Connections.path, inclusive = false) - } - if (uri != null && uri.toString().isNotEmpty()) { - context.sendResponseDeepLink(uri) - } else { - composableScope.launch(Dispatchers.Main) { - Toast.makeText(context, "Go back to your browser", Toast.LENGTH_SHORT).show() - } - } - }, - onError = { error -> - isConfirmLoading = false - showError(navController, error, composableScope, context) - }) - - } catch (e: Throwable) { - showError(navController, e, composableScope, context) - } - }, - onCancel = { - isCancelLoading = true - if (sessionRequestUI.peerUI.linkMode) { - navController.popBackStack(route = Route.Connections.path, inclusive = false) - } - try { - sessionRequestViewModel.reject( - onSuccess = { uri -> - isCancelLoading = false - composableScope.launch(Dispatchers.Main) { - navController.popBackStack(route = Route.Connections.path, inclusive = false) - } - if (uri != null && uri.toString().isNotEmpty()) { - context.sendResponseDeepLink(uri) - } else { - composableScope.launch(Dispatchers.Main) { - Toast.makeText(context, "Go back to your browser", Toast.LENGTH_SHORT).show() - } - } - }, - onError = { error -> - isCancelLoading = false - showError(navController, error, composableScope, context) - }) - } catch (e: Throwable) { - showError(navController, e, composableScope, context) - } - }, + onConfirm = { confirmRequest(sessionRequestUI, navController, sessionRequestViewModel, composableScope, context) { isConfirmLoading = it } }, + onCancel = { cancelRequest(sessionRequestUI, navController, sessionRequestViewModel, composableScope, context) { isCancelLoading = it } }, isLoadingConfirm = isConfirmLoading, isLoadingCancel = isCancelLoading ) @@ -171,6 +114,79 @@ fun SessionRequestRoute(navController: NavHostController, sessionRequestViewMode } } +private fun cancelRequest( + sessionRequestUI: SessionRequestUI.Content, + navController: NavHostController, + sessionRequestViewModel: SessionRequestViewModel, + composableScope: CoroutineScope, + context: Context, + toggleCancelLoader: (Boolean) -> Unit +) { + toggleCancelLoader(true) + if (sessionRequestUI.peerUI.linkMode) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + try { + sessionRequestViewModel.reject( + onSuccess = { uri -> + toggleCancelLoader(false) + composableScope.launch(Dispatchers.Main) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + if (uri != null && uri.toString().isNotEmpty()) { + context.sendResponseDeepLink(uri) + } else { + composableScope.launch(Dispatchers.Main) { + Toast.makeText(context, "Go back to your browser", Toast.LENGTH_SHORT).show() + } + } + }, + onError = { error -> + toggleCancelLoader(false) + showError(navController, error, composableScope, context) + }) + } catch (e: Throwable) { + showError(navController, e, composableScope, context) + } +} + +private fun confirmRequest( + sessionRequestUI: SessionRequestUI.Content, + navController: NavHostController, + sessionRequestViewModel: SessionRequestViewModel, + composableScope: CoroutineScope, + context: Context, + toggleConfirmLoader: (Boolean) -> Unit +) { + toggleConfirmLoader(true) + if (sessionRequestUI.peerUI.linkMode) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + try { + sessionRequestViewModel.approve( + onSuccess = { uri -> + toggleConfirmLoader(false) + composableScope.launch(Dispatchers.Main) { + navController.popBackStack(route = Route.Connections.path, inclusive = false) + } + if (uri != null && uri.toString().isNotEmpty()) { + context.sendResponseDeepLink(uri) + } else { + composableScope.launch(Dispatchers.Main) { + Toast.makeText(context, "Go back to your browser", Toast.LENGTH_SHORT).show() + } + } + }, + onError = { error -> + toggleConfirmLoader(false) + showError(navController, error, composableScope, context) + }) + + } catch (e: Throwable) { + showError(navController, e, composableScope, context) + } +} + private fun showError(navController: NavHostController, throwable: Throwable?, coroutineScope: CoroutineScope, context: Context) { coroutineScope.launch(Dispatchers.Main) { if (throwable !is NoConnectivityException) { diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestUI.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestUI.kt similarity index 96% rename from sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestUI.kt rename to sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestUI.kt index 4936c7e87..4cd1e5177 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestUI.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestUI.kt @@ -1,4 +1,4 @@ -package com.reown.sample.wallet.ui.routes.dialog_routes.session_request +package com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request import com.reown.sample.wallet.ui.common.peer.PeerContextUI import com.reown.sample.wallet.ui.common.peer.PeerUI diff --git a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestViewModel.kt b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestViewModel.kt similarity index 54% rename from sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestViewModel.kt rename to sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestViewModel.kt index 19f15b78a..20fcfb175 100644 --- a/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/SessionRequestViewModel.kt +++ b/sample/wallet/src/main/kotlin/com/reown/sample/wallet/ui/routes/dialog_routes/session_request/request/SessionRequestViewModel.kt @@ -1,35 +1,62 @@ -package com.reown.sample.wallet.ui.routes.dialog_routes.session_request +package com.reown.sample.wallet.ui.routes.dialog_routes.session_request.request import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase -import com.reown.android.cacao.signature.SignatureType import com.reown.android.internal.common.exception.NoConnectivityException -import com.reown.android.utils.cacao.sign -import com.reown.sample.common.Chains -import com.reown.sample.wallet.domain.EthAccountDelegate +import com.reown.sample.wallet.domain.Signer +import com.reown.sample.wallet.domain.Signer.PERSONAL_SIGN import com.reown.sample.wallet.domain.WCDelegate import com.reown.sample.wallet.ui.common.peer.PeerUI import com.reown.sample.wallet.ui.common.peer.toPeerUI -import com.reown.util.hexToBytes import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.reown.walletkit.utils.CacaoSigner +import kotlinx.coroutines.launch import org.json.JSONArray import org.web3j.utils.Numeric.hexStringToByteArray class SessionRequestViewModel : ViewModel() { var sessionRequestUI: SessionRequestUI = generateSessionRequestUI() - private fun clearSessionRequest() { - WCDelegate.sessionRequestEvent = null - WCDelegate.currentId = null - sessionRequestUI = SessionRequestUI.Initial + fun approve(onSuccess: (Uri?) -> Unit = {}, onError: (Throwable) -> Unit = {}) { + viewModelScope.launch { + try { + val sessionRequest = sessionRequestUI as? SessionRequestUI.Content + if (sessionRequest != null) { + val result: String = Signer.sign(sessionRequest) + val response = Wallet.Params.SessionRequestResponse( + sessionTopic = sessionRequest.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult(sessionRequest.requestId, result) + ) + val redirect = WalletKit.getActiveSessionByTopic(sessionRequest.topic)?.redirect?.toUri() + WalletKit.respondSessionRequest(response, + onSuccess = { + clearSessionRequest() + onSuccess(redirect) + }, + onError = { error -> + Firebase.crashlytics.recordException(error.throwable) + if (error.throwable !is NoConnectivityException) { + clearSessionRequest() + } + onError(error.throwable) + }) + } else { + onError(Throwable("Approve - Cannot find session request")) + } + } catch (e: Exception) { + Firebase.crashlytics.recordException(e) + reject(message = e.message ?: "Undefined error, please check your Internet connection") + clearSessionRequest() + onError(Throwable(e.message ?: "Undefined error, please check your Internet connection")) + } + } } - fun reject(onSuccess: (Uri?) -> Unit = {}, onError: (Throwable) -> Unit = {}) { + fun reject(onSuccess: (Uri?) -> Unit = {}, onError: (Throwable) -> Unit = {}, message: String = "User rejected the request") { try { val sessionRequest = sessionRequestUI as? SessionRequestUI.Content if (sessionRequest != null) { @@ -38,7 +65,7 @@ class SessionRequestViewModel : ViewModel() { jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( id = sessionRequest.requestId, code = 500, - message = "Kotlin Wallet Error" + message = message ) ) val redirect = WalletKit.getActiveSessionByTopic(sessionRequest.topic)?.redirect?.toUri() @@ -60,7 +87,7 @@ class SessionRequestViewModel : ViewModel() { } catch (e: Exception) { Firebase.crashlytics.recordException(e) clearSessionRequest() - onError(e.cause ?: Throwable("Undefined error, please check your Internet connection")) + onError(Throwable(e.message ?: "Undefined error, please check your Internet connection")) } } @@ -77,57 +104,10 @@ class SessionRequestViewModel : ViewModel() { } } - fun approve(onSuccess: (Uri?) -> Unit = {}, onError: (Throwable) -> Unit = {}) { - try { - val sessionRequest = sessionRequestUI as? SessionRequestUI.Content - if (sessionRequest != null) { - val result: String = when { - sessionRequest.method == PERSONAL_SIGN_METHOD -> CacaoSigner.sign( - sessionRequest.param, - EthAccountDelegate.privateKey.hexToBytes(), - SignatureType.EIP191 - ).s - - sessionRequest.chain?.contains(Chains.Info.Eth.chain, true) == true -> - """0xa3f20717a250c2b0b729b7e5becbff67fdaef7e0699da4de7ca5895b02a170a12d887fd3b17bfdce3481f10bea41f45ba9f709d39ce8325427b57afcfc994cee1b""" - - sessionRequest.chain?.contains(Chains.Info.Cosmos.chain, true) == true -> - """{"signature":"pBvp1bMiX6GiWmfYmkFmfcZdekJc19GbZQanqaGa\/kLPWjoYjaJWYttvm17WoDMyn4oROas4JLu5oKQVRIj911==","pub_key":{"value":"psclI0DNfWq6cOlGrKD9wNXPxbUsng6Fei77XjwdkPSt","type":"tendermint\/PubKeySecp256k1"}}""" - //Note: Only for testing purposes - it will always fail on Dapp side - sessionRequest.chain?.contains(Chains.Info.Solana.chain, true) == true -> - """{"signature":"pBvp1bMiX6GiWmfYmkFmfcZdekJc19GbZQanqaGa\/kLPWjoYjaJWYttvm17WoDMyn4oROas4JLu5oKQVRIj911==","pub_key":{"value":"psclI0DNfWq6cOlGrKD9wNXPxbUsng6Fei77XjwdkPSt","type":"tendermint\/PubKeySecp256k1"}}""" - - else -> throw Exception("Unsupported Chain") - } - val response = Wallet.Params.SessionRequestResponse( - sessionTopic = sessionRequest.topic, - jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( - sessionRequest.requestId, - result - ) - ) - - val redirect = WalletKit.getActiveSessionByTopic(sessionRequest.topic)?.redirect?.toUri() - WalletKit.respondSessionRequest(response, - onSuccess = { - clearSessionRequest() - onSuccess(redirect) - }, - onError = { error -> - Firebase.crashlytics.recordException(error.throwable) - if (error.throwable !is NoConnectivityException) { - clearSessionRequest() - } - onError(error.throwable) - }) - } else { - onError(Throwable("Approve - Cannot find session request")) - } - } catch (e: Exception) { - Firebase.crashlytics.recordException(e) - clearSessionRequest() - onError(e.cause ?: Throwable("Undefined error, please check your Internet connection")) - } + private fun clearSessionRequest() { + WCDelegate.sessionRequestEvent = null + WCDelegate.currentId = null + sessionRequestUI = SessionRequestUI.Initial } private fun generateSessionRequestUI(): SessionRequestUI { @@ -143,7 +123,7 @@ class SessionRequestViewModel : ViewModel() { ), topic = sessionRequest.topic, requestId = sessionRequest.request.id, - param = if (sessionRequest.request.method == PERSONAL_SIGN_METHOD) extractMessageParamFromPersonalSign(sessionRequest.request.params) else sessionRequest.request.params, + param = if (sessionRequest.request.method == PERSONAL_SIGN) extractMessageParamFromPersonalSign(sessionRequest.request.params) else sessionRequest.request.params, chain = sessionRequest.chainId, method = sessionRequest.request.method, peerContextUI = context.toPeerUI() @@ -152,6 +132,4 @@ class SessionRequestViewModel : ViewModel() { SessionRequestUI.Initial } } -} - -private const val PERSONAL_SIGN_METHOD = "personal_sign" \ No newline at end of file +} \ No newline at end of file diff --git a/sample/wallet/src/main/res/drawable/base_network_logo.xml b/sample/wallet/src/main/res/drawable/base_network_logo.xml new file mode 100644 index 000000000..bd941f5bc --- /dev/null +++ b/sample/wallet/src/main/res/drawable/base_network_logo.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/sample/wallet/src/main/res/drawable/ic_arbitrum.xml b/sample/wallet/src/main/res/drawable/ic_arbitrum.xml new file mode 100644 index 000000000..0d9828e2c --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_arbitrum.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/sample/wallet/src/main/res/drawable/ic_base.xml b/sample/wallet/src/main/res/drawable/ic_base.xml new file mode 100644 index 000000000..bd21228d4 --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_base.xml @@ -0,0 +1,12 @@ + + + + diff --git a/sample/wallet/src/main/res/drawable/ic_bridge.xml b/sample/wallet/src/main/res/drawable/ic_bridge.xml new file mode 100644 index 000000000..90bae62bd --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_bridge.xml @@ -0,0 +1,9 @@ + + + diff --git a/sample/wallet/src/main/res/drawable/ic_ca_error.xml b/sample/wallet/src/main/res/drawable/ic_ca_error.xml new file mode 100644 index 000000000..2fb8e7092 --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_ca_error.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/sample/wallet/src/main/res/drawable/ic_frame.xml b/sample/wallet/src/main/res/drawable/ic_frame.xml new file mode 100644 index 000000000..c00c6fd77 --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_frame.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/sample/wallet/src/main/res/drawable/ic_optimism.xml b/sample/wallet/src/main/res/drawable/ic_optimism.xml new file mode 100644 index 000000000..df8406745 --- /dev/null +++ b/sample/wallet/src/main/res/drawable/ic_optimism.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/sample/wallet/src/main/res/drawable/optimism.xml b/sample/wallet/src/main/res/drawable/optimism.xml new file mode 100644 index 000000000..59f2f4d56 --- /dev/null +++ b/sample/wallet/src/main/res/drawable/optimism.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/sample/wallet/src/test/kotlin/com/reown/sample/wallet/domain/EthSignerTest.kt b/sample/wallet/src/test/kotlin/com/reown/sample/wallet/domain/EthSignerTest.kt new file mode 100644 index 000000000..d2eba3371 --- /dev/null +++ b/sample/wallet/src/test/kotlin/com/reown/sample/wallet/domain/EthSignerTest.kt @@ -0,0 +1,25 @@ +package com.reown.sample.wallet.domain + +import org.junit.Test + +class EthSignerTest { + + @Test + fun testSignHash1() { + val signature = EthSigner.signHash( + hashToSign = "0xaceca6583d6e6afa780127d5125edf94b157b2685ff81116339d0ca6752937a4", + privateKey = "2727fcadfbc14134d6cfd50c2cb70e85870e16b32ca790822ea3d75e6e8b72a3" + ) + + assert(signature == "0xf7d4d35037d8cf5c757f0761fe84e312ba40a9493e868b5d3066b84a8dfc1bbb13dafc39e0dd6ef6a44cf17007580ef3ec8051097bfdab3996070971ae5cb13a1b") + } + @Test + fun testSignHash2() { + val signature = EthSigner.signHash( + hashToSign = "0xc3a9774d06728aaf600f0813355d7624eacb7a71f32359f1670efa0112613e1b", + privateKey = "d2c26fe51164acb58719f0da5977df0061b7cc755695805d6af482cb37f02128" + ) + + assert(signature == "0xbdd007ae8b9e23e989873e80bc7aad05fb84fc7ad47a347b3002fdf0bae7bb8f7707fc6f7c241a41233c54e0c7c89ca75ad606eb95d6c18b7cbd09eba2361df81c") + } +} \ No newline at end of file