diff --git a/app/build.gradle b/app/build.gradle index 34e2325..cebe6ca 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,6 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.compose' id("kotlin-kapt") id("kotlinx-serialization") } @@ -20,6 +19,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + buildTypes { release { minifyEnabled true @@ -39,34 +42,32 @@ android { compose = true } + configurations{ + all*.exclude module: 'bcprov-jdk15on' + } + packagingOptions { resources { excludes += ['META-INF/AL2.0', 'META-INF/LGPL2.1', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] } } - - configurations{ - all*.exclude module: 'bcprov-jdk15on' - } } dependencies { implementation project(path: ':cartera') implementation 'androidx.core:core-ktx:1.16.0' - implementation platform('org.jetbrains.kotlin:kotlin-bom:2.0.21') - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.2' implementation 'androidx.activity:activity-compose:1.10.1' - implementation platform('androidx.compose:compose-bom:2025.06.01') - implementation "androidx.compose.runtime:runtime" implementation 'com.google.accompanist:accompanist-navigation-material:0.36.0' + implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material:material' - implementation 'androidx.navigation:navigation-runtime-ktx:2.9.1' - implementation 'androidx.navigation:navigation-compose:2.9.1' + implementation 'androidx.navigation:navigation-runtime-ktx:2.9.2' + implementation 'androidx.navigation:navigation-compose:2.9.2' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' @@ -82,12 +83,6 @@ dependencies { implementation 'com.solanamobile:web3-solana:0.2.5' implementation 'com.solanamobile:rpc-core:0.2.8' - implementation 'io.github.funkatronics:kborsh:0.1.1' - implementation 'io.github.funkatronics:multimult:0.2.4' - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - implementation("io.ktor:ktor-client-core:2.3.4") - implementation("io.ktor:ktor-client-android:2.3.4") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.google.code.gson:gson:2.13.1") diff --git a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt index 36037f4..d2e1fda 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt @@ -7,6 +7,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.MaterialTheme @@ -27,7 +28,7 @@ import exchange.dydx.cartera.walletprovider.providers.WalletConnectModalProvider class MainActivity : ComponentActivity() { - @OptIn(ExperimentalMaterialNavigationApi::class) + @OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalMaterialApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletList.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletList.kt index 3822218..c22ed9e 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletList.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletList.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetState @@ -75,6 +76,7 @@ object WalletList { var useWcModal: Boolean by mutableStateOf(false) } + @OptIn(ExperimentalMaterialApi::class) @SuppressLint("CoroutineCreationDuringComposition") @Composable fun Content() { @@ -119,6 +121,7 @@ object WalletList { } } + @OptIn(ExperimentalMaterialApi::class) @Composable fun WalletListContent( viewState: WalletList.WalletListState, diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 35e1eb3..5d82db4 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt @@ -13,6 +13,7 @@ import exchange.dydx.cartera.CarteraConfig import exchange.dydx.cartera.CarteraConstants import exchange.dydx.cartera.CarteraProvider import exchange.dydx.cartera.entities.Wallet +import exchange.dydx.cartera.solana.SolanaInteractor import exchange.dydx.cartera.tag import exchange.dydx.cartera.typeddata.EIP712DomainTypedDataProvider import exchange.dydx.cartera.typeddata.WalletTypedData @@ -22,7 +23,6 @@ import exchange.dydx.cartera.walletprovider.WalletRequest import exchange.dydx.cartera.walletprovider.WalletStatusDelegate import exchange.dydx.cartera.walletprovider.WalletStatusProtocol import exchange.dydx.cartera.walletprovider.WalletTransactionRequest -import exchange.dydx.carteraexample.solana.SolanaInteractor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/build.gradle b/build.gradle index a52f4a6..3d2c20e 100644 --- a/build.gradle +++ b/build.gradle @@ -10,13 +10,12 @@ buildscript { // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.11.0' apply false - id 'com.android.library' version '8.11.0' apply false - id 'org.jetbrains.kotlin.android' version '2.2.0' apply false - id 'org.jetbrains.kotlin.plugin.compose' version '2.2.0' apply false - id 'com.google.dagger.hilt.android' version '2.56.2' apply false - id "com.diffplug.spotless" version "7.0.4" // apply false - id "org.jetbrains.kotlin.plugin.serialization" version "2.2.0" apply false + id 'com.android.application' version '8.11.1' apply false + id 'com.android.library' version '8.11.1' apply false + id 'org.jetbrains.kotlin.android' version '1.9.24' apply false + id 'com.google.dagger.hilt.android' version '2.57' apply false + id "com.diffplug.spotless" version "7.2.1" // apply false + id "org.jetbrains.kotlin.plugin.serialization" version "1.9.24" apply false } allprojects { diff --git a/cartera/build.gradle b/cartera/build.gradle index 3206d49..5281293 100644 --- a/cartera/build.gradle +++ b/cartera/build.gradle @@ -47,14 +47,17 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.15.0' - implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.core:core-ktx:1.16.0' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'com.google.android.material:material:1.12.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' // implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + implementation 'com.solanamobile:web3-solana:0.2.5' + implementation 'com.solanamobile:rpc-core:0.2.8' + implementation 'com.google.code.gson:gson:2.13.1' // diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt b/cartera/src/main/java/exchange/dydx/cartera/solana/SolanaInteractor.kt similarity index 55% rename from app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt rename to cartera/src/main/java/exchange/dydx/cartera/solana/SolanaInteractor.kt index be14f0e..5cef238 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/solana/SolanaInteractor.kt @@ -1,4 +1,4 @@ -package exchange.dydx.carteraexample.solana +package exchange.dydx.cartera.solana import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.solana.publickey.SolanaPublicKey @@ -10,12 +10,15 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import timber.log.Timber import kotlin.math.max import kotlin.math.pow class SolanaInteractor( private val rpcUrl: String, ) { + private val TAG = "SolanaInteractor" + companion object { val mainnetUrl = "https://api.mainnet-beta.solana.com" val devnetUrl = "https://api.devnet.solana.com" @@ -41,14 +44,22 @@ class SolanaInteractor( .post(requestBody) .build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - println("Request failed: ${response.code}") + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.tag(TAG).e("Request failed: ${response.code}") + return@withContext null + } + + val responseBody = response.body?.string() ?: return@withContext null + return@withContext gson.fromJson( + responseBody, + LatestBlockhashResponse::class.java, + ).result + } catch (e: Exception) { + Timber.tag(TAG).e("Request failed: ${e.message}") return@withContext null } - - val responseBody = response.body?.string() ?: return@withContext null - return@withContext gson.fromJson(responseBody, LatestBlockhashResponse::class.java).result } suspend fun getBalance(publicKey: String): Double? = withContext(Dispatchers.IO) { @@ -72,15 +83,20 @@ class SolanaInteractor( .post(requestBody) .build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - println("Request failed: ${response.code}") + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.tag(TAG).e("Request failed: ${response.code}") + return@withContext null + } + + val body = response.body?.string() ?: return@withContext null + val parsed = gson.fromJson(body, BalanceResponse::class.java) + return@withContext parsed.result.value.toDouble() / 10.0.pow(9.0) + } catch (e: Exception) { + Timber.tag(TAG).e("Request failed: ${e.message}") return@withContext null } - - val body = response.body?.string() ?: return@withContext null - val parsed = gson.fromJson(body, BalanceResponse::class.java) - return@withContext parsed.result.value.toDouble() / 10.0.pow(9.0) } suspend fun getTokenBalance(publicKey: String, tokenAddress: String): Double? = withContext(Dispatchers.IO) { @@ -110,22 +126,71 @@ class SolanaInteractor( .post(requestBody) .build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - println("Request failed: ${response.code}") + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.tag(TAG).e("Request failed: ${response.code}") + return@withContext null + } + + try { + val parsed = + gson.fromJson(response.body?.string(), TokenAccountsResponse::class.java) + var balance = 0.0f + for (account in parsed.result.value) { + val tokenAmount = account.account.data.parsed.info.tokenAmount.uiAmount + balance = max(balance, tokenAmount) + } + return@withContext balance.toDouble() + } catch (e: Exception) { + Timber.tag(TAG).e("Failed to parse response: ${e.message}") + return@withContext null + } + } catch (e: Exception) { + Timber.tag(TAG).e("Request failed: ${e.message}") return@withContext null } + } + + suspend fun sendRawTransaction(base58Tx: String): String? = withContext(Dispatchers.IO) { + val client = OkHttpClient() + val gson = Gson() + + val json = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to "sendTransaction", + "params" to listOf(base58Tx, mapOf("encoding" to "base58")), + ) + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + gson.toJson(json), + ) + + val request = Request.Builder() + .url(rpcUrl) + .post(requestBody) + .build() try { - val parsed = gson.fromJson(response.body?.string(), TokenAccountsResponse::class.java) - var balance = 0.0f - for (account in parsed.result.value) { - val tokenAmount = account.account.data.parsed.info.tokenAmount.uiAmount - balance = max(balance, tokenAmount) + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + println("Request failed: ${response.code}") + return@withContext null + } + + try { + val json = response.body?.string() ?: return@withContext null + val parsed = + gson.fromJson(json, SendTransactionResponse::class.java) + return@withContext parsed.result + } catch (e: Exception) { + Timber.tag(TAG).e("Failed to parse response: ${e.message}") + return@withContext null } - return@withContext balance.toDouble() } catch (e: Exception) { - println("Failed to parse response: ${e.message}") + Timber.tag(TAG).e("Request failed: ${e.message}") return@withContext null } } @@ -213,3 +278,9 @@ data class TokenAmount( val decimals: Int, val uiAmount: Float ) + +data class SendTransactionResponse( + val jsonrpc: String, + val result: String, // the transaction signature (base58) + val id: Int +) diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt b/cartera/src/main/java/exchange/dydx/cartera/solana/SystemProgram.kt similarity index 96% rename from app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt rename to cartera/src/main/java/exchange/dydx/cartera/solana/SystemProgram.kt index e2a16e5..907d33b 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/solana/SystemProgram.kt @@ -1,4 +1,4 @@ -package exchange.dydx.carteraexample.solana +package exchange.dydx.cartera.solana import com.solana.publickey.SolanaPublicKey import com.solana.transaction.AccountMeta diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt index b4375ba..9e2e777 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -13,6 +13,7 @@ import exchange.dydx.cartera.PhantomWalletConfig import exchange.dydx.cartera.decodeBase58 import exchange.dydx.cartera.encodeToBase58String import exchange.dydx.cartera.entities.Wallet +import exchange.dydx.cartera.solana.SolanaInteractor import exchange.dydx.cartera.tag import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol import exchange.dydx.cartera.typeddata.typedDataAsString @@ -47,7 +48,6 @@ class PhantomWalletProvider( onDisconnect("disconnect"), onSignMessage("signMessage"), onSignTransaction("signTransaction"), - onSendTransaction("signAndSendTransaction") } private var _walletStatus = WalletStatusImp() @@ -195,38 +195,6 @@ class PhantomWalletProvider( } } } - - CallbackAction.onSendTransaction.name -> { - if (operationCompletion != null) { - if (errorCode != null) { - CoroutineScope(Dispatchers.Main).launch { - operationCompletion?.invoke(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, errorMessage)) - operationCompletion = null - } - } else { - val data = decryptPayload( - payload = uri.getQueryParameter("data"), - nonce = uri.getQueryParameter("nonce"), - ) - val response = try { - Gson().fromJson(data?.decodeToString(), SendTransactionResponse::class.java) - } catch (e: Exception) { - null - } - if (response != null) { - CoroutineScope(Dispatchers.Main).launch { - operationCompletion?.invoke(response.signature, null) - operationCompletion = null - } - } else { - CoroutineScope(Dispatchers.Main).launch { - operationCompletion?.invoke(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to decrypt payload")) - operationCompletion = null - } - } - } - } - } } return true @@ -362,6 +330,29 @@ class PhantomWalletProvider( ) } + private fun doSignTransaction( + transaction: String, + completion: WalletOperationCompletion + ) { + val request = SignTransactionRequest( + session = session, + transaction = transaction, + ) + val uri = createRequestUri( + request = Gson().toJson(request), + action = CallbackAction.onSignTransaction, + ) + if (uri != null) { + if (openPeerDeeplink(uri)) { + operationCompletion = completion + } else { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to open Phantom app")) + } + } else { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to create request URI")) + } + } + override fun send( request: WalletTransactionRequest, connected: WalletConnectedCompletion?, @@ -388,22 +379,32 @@ class PhantomWalletProvider( return } - val sendRequest = SendTransactionRequest( - session = session, - transaction = data.encodeToBase58String(), - ) - val uri = createRequestUri( - request = Gson().toJson(sendRequest), - action = CallbackAction.onSendTransaction, - ) - if (uri != null) { - if (openPeerDeeplink(uri)) { - operationCompletion = completion - } else { - completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to open Phantom app")) + doSignTransaction(transaction = data.encodeToBase58String()) { signed, error -> + if (error != null || signed == null) { + completion(null, error) + return@doSignTransaction + } + + val isMainnet = request.walletRequest.chainId == "1" + val solanaInteractor = SolanaInteractor( + rpcUrl = if (isMainnet) { + SolanaInteractor.mainnetUrl + } else { + SolanaInteractor.devnetUrl + }, + ) + + val scope = CoroutineScope(Dispatchers.Unconfined) + scope.launch { + val hash = solanaInteractor.sendRawTransaction(signed) + CoroutineScope(Dispatchers.Main).launch { + if (hash != null) { + completion(hash, null) + } else { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to send transaction")) + } + } } - } else { - completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to create request URI")) } } diff --git a/gradle.properties b/gradle.properties index 52cb355..1b99ea2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,6 +26,6 @@ android.nonTransitiveRClass=true LIBRARY_GROUP=dydxprotocol LIBRARY_ARTIFACT_ID=cartera-android -LIBRARY_VERSION_NAME=0.1.23 +LIBRARY_VERSION_NAME=0.1.24 android.enableR8.fullMode = false \ No newline at end of file