diff --git a/README.md b/README.md index 2ee3010..a05ecc7 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ apply from: 'publishLocal.gradle' ``` Build and push the code to Maven Local repo with: ``` -./gradlew publishLibraryDebugPublicationToMavenLocal +./gradlew publishToMavenLocal ``` Then add "-local-debug" to the library import from the main app's build.gradle ``` diff --git a/app/build.gradle b/app/build.gradle index ef8f9a1..f62b94d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,8 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id("kotlin-kapt") + id("kotlinx-serialization") } android { @@ -10,7 +12,7 @@ android { defaultConfig { applicationId "exchange.dydx.carteraexample" minSdk 24 - targetSdk 34 + targetSdk 35 versionCode 1 versionName "1.0" @@ -36,7 +38,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion "1.4.7" + kotlinCompilerExtensionVersion "1.5.14" } configurations{ @@ -50,20 +52,20 @@ dependencies { implementation 'androidx.core:core-ktx:1.15.0' implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.24') implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' - implementation 'androidx.activity:activity-compose:1.9.3' - implementation platform('androidx.compose:compose-bom:2024.11.00') + implementation 'androidx.activity:activity-compose:1.10.1' + implementation platform('androidx.compose:compose-bom:2025.03.00') implementation 'com.google.accompanist:accompanist-navigation-material:0.34.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.8.4' - implementation 'androidx.navigation:navigation-compose:2.8.4' + implementation 'androidx.navigation:navigation-runtime-ktx:2.8.9' + implementation 'androidx.navigation:navigation-compose:2.8.9' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - androidTestImplementation platform('androidx.compose:compose-bom:2024.11.00') + androidTestImplementation platform('androidx.compose:compose-bom:2025.03.00') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' @@ -73,4 +75,16 @@ dependencies { implementation platform('com.walletconnect:android-bom:1.35.2') implementation("com.walletconnect:android-core") implementation("com.walletconnect:walletconnect-modal") + + implementation 'com.solanamobile:web3-solana:0.2.5' + implementation 'com.solanamobile:rpc-core:0.2.8' + implementation 'io.github.funkatronics:kborsh:0.1.0' + implementation 'io.github.funkatronics:multimult:0.2.3' + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + 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.10.1") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64c0902..7e070d7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CarteraExample" - tools:targetApi="31" > + tools:targetApi="31" + android:launchMode="singleTop"> - - - + + + + - + - + @@ -46,6 +52,7 @@ + \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt index 299215d..36037f4 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt @@ -1,6 +1,8 @@ package exchange.dydx.carteraexample import android.app.Application +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -29,6 +31,12 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val action: String? = intent?.action + val data: Uri? = intent?.data + if (action == "android.intent.action.VIEW" && data != null) { + CarteraConfig.handleResponse(data) + } + val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val uri = result.data?.data ?: return@registerForActivityResult CarteraConfig.handleResponse(uri) @@ -73,6 +81,19 @@ class MainActivity : ComponentActivity() { } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // must store the new intent unless getIntent() + // will return the old one + setIntent(intent) + + val action: String? = intent.action + val data: Uri? = intent.data + if (action == "android.intent.action.VIEW" && data != null) { + CarteraConfig.handleResponse(data) + } + } } @Composable diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 87b4ca0..35e1eb3 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt @@ -1,12 +1,14 @@ package exchange.dydx.carteraexample import android.content.Context -import android.util.Log import android.widget.Toast import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.Message +import com.solana.transaction.Transaction import exchange.dydx.cartera.CarteraConfig import exchange.dydx.cartera.CarteraConstants import exchange.dydx.cartera.CarteraProvider @@ -20,7 +22,11 @@ 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 +import timber.log.Timber import java.math.BigInteger class WalletListViewModel( @@ -40,7 +46,11 @@ class WalletListViewModel( viewState.value = WalletList.WalletListState( wallets = CarteraConfig.shared?.wallets ?: listOf(), walletAction = { action: WalletList.WalletAction, wallet: Wallet?, useTestnet: Boolean, useModal: Boolean -> - val chainId: String = if (useTestnet) CarteraConstants.testnetChainId else "1" + val chainId: String = if (useTestnet) { + CarteraConstants.testnetChainId + } else { + "1" + } when (action) { WalletList.WalletAction.Connect -> { testConnect(wallet, chainId, useModal) @@ -55,7 +65,7 @@ class WalletListViewModel( } WalletList.WalletAction.SignTransaction -> { - testSendTransaction(wallet, chainId, useModal) + testSendTransaction(wallet, chainId, useTestnet, useModal) } WalletList.WalletAction.Disconnect -> { @@ -127,10 +137,12 @@ class WalletListViewModel( request = request, message = "Test Message", connected = { info -> - Log.d(tag(this@WalletListViewModel), "Connected to: ${info?.peerName ?: info?.address}") + Timber.tag(tag(this@WalletListViewModel)) + .d("Connected to: ${info?.peerName ?: info?.address}") }, status = { requireAppSwitching -> - Log.d(tag(this@WalletListViewModel), "Require app switching: $requireAppSwitching") + Timber.tag(tag(this@WalletListViewModel)) + .d("Require app switching: $requireAppSwitching") toastMessage("Please switch to the wallet app") }, completion = { signature, error -> @@ -146,8 +158,13 @@ class WalletListViewModel( } private fun testSignTypedData(wallet: Wallet?, chainId: String, useModal: Boolean) { - val dydxSign = EIP712DomainTypedDataProvider(name = "dYdX", chainId = chainId.toInt()) - dydxSign.message = message(action = "Sample Action", chainId = chainId.toInt()) + val chainIdInt = chainId.toIntOrNull() + if (chainIdInt == null) { + toastMessage("Invalid chainId: $chainId, must be an integer") + return + } + val dydxSign = EIP712DomainTypedDataProvider(name = "dYdX", chainId = chainIdInt) + dydxSign.message = message(action = "Sample Action", chainId = chainIdInt) val request = WalletRequest(wallet = wallet, address = null, chainId = chainId, context = context, useModal = useModal) provider.sign( @@ -157,7 +174,8 @@ class WalletListViewModel( toastMessage("Connected to: ${info?.peerName ?: info?.address}") }, status = { requireAppSwitching -> - Log.d(tag(this@WalletListViewModel), "Require app switching: $requireAppSwitching") + Timber.tag(tag(this@WalletListViewModel)) + .d("Require app switching: $requireAppSwitching") toastMessage("Please switch to the wallet app") }, completion = { signature, error -> @@ -172,50 +190,99 @@ class WalletListViewModel( ) } - private fun testSendTransaction(wallet: Wallet?, chainId: String, useModal: Boolean) { + private fun testSendTransaction(wallet: Wallet?, chainId: String, useTestnet: Boolean, useModal: Boolean) { val request = WalletRequest(wallet = wallet, address = null, chainId = chainId, context = context, useModal = useModal) provider.connect(request) { info, error -> if (error != null) { toastWalletError(error) } else { - val ethereumRequest = EthereumTransactionRequest( - fromAddress = info?.address ?: "0x00", - toAddress = "0x0000000000000000000000000000000000000000", - weiValue = BigInteger("1"), - data = "0x", - nonce = null, - gasPriceInWei = BigInteger("100000000"), - maxFeePerGas = null, - maxPriorityFeePerGas = null, - gasLimit = BigInteger("21000"), - chainId = chainId.toString(), - ) - val request = - WalletTransactionRequest(walletRequest = request, ethereum = ethereumRequest) - provider.send( - request = request, - connected = { info -> - toastMessage("Connected to: ${info?.peerName ?: info?.address}") - }, - status = { requireAppSwitching -> - Log.d(tag(this@WalletListViewModel), "Require app switching: $requireAppSwitching") - toastMessage("Please switch to the wallet app") - }, - - completion = { txHash, error -> - // delay for 1 second - Thread.sleep(1000) - if (error != null) { - toastWalletError(error) + val publicKey = info?.address + if (wallet?.id == "phantom-wallet" && publicKey != null) { + val interactor = if (useTestnet) { + SolanaInteractor(SolanaInteractor.devnetUrl) + } else { + SolanaInteractor(SolanaInteractor.mainnetUrl) + } + val scope = CoroutineScope(Dispatchers.Unconfined) + scope.launch { + val response = interactor.getRecentBlockhash() + val blockhash = response?.value?.blockhash + if (blockhash != null) { +// val sss = interactor.getBalance(publicKey = publicKey) +// print(sss) +// val ttt = interactor.getTokenBalance(publicKey = publicKey, tokenAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") +// print(ttt) + + val memoInstruction = interactor.buildTestMemoTransaction(address = SolanaPublicKey.from(publicKey), memo = "Hello, Solana!") + val memoTxMessage = Message.Builder() + .addInstruction(memoInstruction) // Pass in instruction from previous step + .setRecentBlockhash(blockhash) + .build() + val unsignedTx = Transaction(memoTxMessage) + val request = + WalletTransactionRequest( + walletRequest = request, + ethereum = null, + solana = unsignedTx.serialize(), + ) + CoroutineScope(Dispatchers.Main).launch { + doSendTransaction(request) + } } else { - toastMessage("Transaction Hash: $txHash") + CoroutineScope(Dispatchers.Main).launch { + toastMessage("Error fetching blockhash") + } } - }, - ) + } + } else { + val ethereumRequest = EthereumTransactionRequest( + fromAddress = info?.address ?: "0x00", + toAddress = "0x0000000000000000000000000000000000000000", + weiValue = BigInteger("1"), + data = "0x", + nonce = null, + gasPriceInWei = BigInteger("100000000"), + maxFeePerGas = null, + maxPriorityFeePerGas = null, + gasLimit = BigInteger("21000"), + chainId = chainId.toString(), + ) + val request = + WalletTransactionRequest( + walletRequest = request, + ethereum = ethereumRequest, + solana = null, + ) + doSendTransaction(request) + } } } } + private fun doSendTransaction(request: WalletTransactionRequest) { + provider.send( + request = request, + connected = { info -> + toastMessage("Connected to: ${info?.peerName ?: info?.address}") + }, + status = { requireAppSwitching -> + Timber.tag(tag(this@WalletListViewModel)) + .d("Require app switching: $requireAppSwitching") + toastMessage("Please switch to the wallet app") + }, + + completion = { txHash, error -> + // delay for 1 second + Thread.sleep(1000) + if (error != null) { + toastWalletError(error) + } else { + toastMessage("Transaction Hash: $txHash") + } + }, + ) + } + override fun statusChanged(status: WalletStatusProtocol) { status.connectedWallet?.address?.let { toastMessage("Connected to: $it") diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt index ba387b8..d7bf0b9 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt @@ -1,5 +1,6 @@ package exchange.dydx.carteraexample +import exchange.dydx.cartera.PhantomWalletConfig import exchange.dydx.cartera.WalletConnectModalConfig import exchange.dydx.cartera.WalletConnectV1Config import exchange.dydx.cartera.WalletConnectV2Config @@ -26,14 +27,19 @@ object WalletProvidersConfigUtil { ) val walletSegueConfig = WalletSegueConfig( - callbackUrl = "https://trade.stage.dydx.exchange/walletsegueCarteraExample", + callbackUrl = "https://v4-web-internal.vercel.app/walletsegueCarteraExample", ) + val phantomWalletConfig = PhantomWalletConfig( + callbackUrl = "https://v4-web-internal.vercel.app/phantomCarteraExample", + appUrl = "https://v4-web-internal.vercel.app", + ) return WalletProvidersConfig( walletConnectV1 = walletConnectV1Config, walletConnectV2 = walletConnectV2Config, walletConnectModal = WalletConnectModalConfig.default, walletSegue = walletSegueConfig, + phantomWallet = phantomWalletConfig, ) } } diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt new file mode 100644 index 0000000..be14f0e --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt @@ -0,0 +1,215 @@ +package exchange.dydx.carteraexample.solana +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.AccountMeta +import com.solana.transaction.TransactionInstruction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import kotlin.math.max +import kotlin.math.pow + +class SolanaInteractor( + private val rpcUrl: String, +) { + companion object { + val mainnetUrl = "https://api.mainnet-beta.solana.com" + val devnetUrl = "https://api.devnet.solana.com" + } + + suspend fun getRecentBlockhash(): LatestBlockhashResult? = withContext(Dispatchers.IO) { + val gson = Gson() + val client = OkHttpClient() + + val json = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to "getLatestBlockhash", + ) + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + gson.toJson(json), + ) + + val request = Request.Builder() + .url(rpcUrl) + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + println("Request failed: ${response.code}") + 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) { + val gson = Gson() + val client = OkHttpClient() + + val json = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to "getBalance", + "params" to listOf(publicKey), + ) + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + gson.toJson(json), + ) + + val request = Request.Builder() + .url(rpcUrl) + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + println("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) + } + + suspend fun getTokenBalance(publicKey: String, tokenAddress: String): Double? = withContext(Dispatchers.IO) { + val client = OkHttpClient() + val gson = Gson() + + val json = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to "getTokenAccountsByOwner", + "params" to listOf( + publicKey, + mapOf( + "mint" to tokenAddress, + ), + mapOf("encoding" to "jsonParsed"), + ), + ) + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + gson.toJson(json), + ) + + val request = Request.Builder() + .url(rpcUrl) + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + println("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) { + println("Failed to parse response: ${e.message}") + return@withContext null + } + } + + fun buildTestMemoTransaction(address: SolanaPublicKey, memo: String) = + TransactionInstruction( + programId = SystemProgram.programId, + accounts = listOf(AccountMeta(publicKey = address, isSigner = true, isWritable = true)), + data = memo.encodeToByteArray(), + ) +} + +data class LatestBlockhashResponse( + val result: LatestBlockhashResult +) + +data class LatestBlockhashResult( + val context: ContextInfo, + val value: BlockhashValue +) + +data class ContextInfo( + val slot: Long +) + +data class BlockhashValue( + @SerializedName("blockhash") val blockhash: String, + @SerializedName("lastValidBlockHeight") val lastValidBlockHeight: Long +) + +data class BalanceResponse( + val result: BalanceResult +) + +data class BalanceResult( + val context: ContextInfo, + val value: Long // balance in lamports +) + +data class TokenAccountsResponse( + val result: ResultWrapper +) + +data class ResultWrapper( + val context: Context, + val value: List +) + +data class Context( + val slot: ULong +) + +data class TokenAccount( + val pubkey: String, + val account: AccountDetails +) + +data class AccountDetails( + val data: AccountData, + val executable: Boolean, + val lamports: ULong, + val owner: String, + val rentEpoch: Float +) + +data class AccountData( + val program: String, + val parsed: ParsedData, + val space: Int +) + +data class ParsedData( + val type: String, + val info: TokenInfo +) + +data class TokenInfo( + val mint: String, + val owner: String, + val tokenAmount: TokenAmount +) + +data class TokenAmount( + val amount: String, + val decimals: Int, + val uiAmount: Float +) diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt new file mode 100644 index 0000000..e2a16e5 --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt @@ -0,0 +1,36 @@ +package exchange.dydx.carteraexample.solana + +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.AccountMeta +import com.solana.transaction.TransactionInstruction +import java.nio.ByteBuffer +import java.nio.ByteOrder + +object SystemProgram { + val programId = SolanaPublicKey.from("11111111111111111111111111111111") + + fun transfer( + fromPublicKey: SolanaPublicKey, + toPublicKey: SolanaPublicKey, + lamports: Long + ): TransactionInstruction { + val accounts = listOf( + AccountMeta(fromPublicKey, isSigner = true, isWritable = true), + AccountMeta(toPublicKey, isSigner = false, isWritable = true), + ) + + // SystemProgram Instruction Layout: + // 4 bytes for instruction (u32 LE) + 8 bytes for amount (u64 LE) + val instructionIndex = 2 // Transfer instruction index in SystemProgram + val buffer = ByteBuffer.allocate(12) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(instructionIndex) // instruction enum + buffer.putLong(lamports) + + return TransactionInstruction( + programId = programId, + accounts = accounts, + data = buffer.array(), + ) + } +} diff --git a/build.gradle b/build.gradle index 5796e01..a30dd75 100644 --- a/build.gradle +++ b/build.gradle @@ -10,11 +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.4.2' apply false - id 'com.android.library' version '8.4.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.21' apply false + id 'com.android.application' version '8.9.0' apply false + id 'com.android.library' version '8.9.0' apply false + id 'org.jetbrains.kotlin.android' version '1.9.24' apply false id 'com.google.dagger.hilt.android' version '2.41' apply false id "com.diffplug.spotless" version "6.22.0" // 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 50627a7..1bc24a1 100644 --- a/cartera/build.gradle +++ b/cartera/build.gradle @@ -10,7 +10,7 @@ android { defaultConfig { minSdk 24 - targetSdk 34 + targetSdk 35 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -76,6 +76,14 @@ dependencies { // https://github.com/WalletConnect/kotlin-walletconnect-lib // implementation 'com.github.WalletConnect:kotlin-walletconnect-lib:0.9.9' + + + // + // https://github.com/InstantWebP2P/tweetnacl-java, for Phantom + // + implementation 'io.github.instantwebp2p:tweetnacl-java:1.1.2' + + implementation("com.github.komputing.khash:sha256:1.1.3") } apply plugin: 'maven-publish' diff --git a/cartera/src/main/java/exchange/dydx/cartera/Base58.kt b/cartera/src/main/java/exchange/dydx/cartera/Base58.kt new file mode 100644 index 0000000..2e588c0 --- /dev/null +++ b/cartera/src/main/java/exchange/dydx/cartera/Base58.kt @@ -0,0 +1,159 @@ +package exchange.dydx.cartera + +import org.komputing.khash.sha256.extensions.sha256 + +/** + * Base58 is a way to encode addresses (or arbitrary data) as alphanumeric strings. + * Compared to base64, this encoding eliminates ambiguities created by O0Il and potential splits from punctuation + * + * The basic idea of the encoding is to treat the data bytes as a large number represented using + * base-256 digits, convert the number to be represented using base-58 digits, preserve the exact + * number of leading zeros (which are otherwise lost during the mathematical operations on the + * numbers), and finally represent the resulting base-58 digits as alphanumeric ASCII characters. + * + * This is the Kotlin implementation of base58 - it is based implementation of base58 in java + * in bitcoinj (https://bitcoinj.github.io) - thanks to Google Inc. and Andreas Schildbach + * + */ + +private const val ENCODED_ZERO = '1' +private const val CHECKSUM_SIZE = 4 + +private const val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +private val alphabetIndices by lazy { + IntArray(128) { alphabet.indexOf(it.toChar()) } +} + +/** + * Encodes the bytes as a base58 string (no checksum is appended). + * + * @return the base58-encoded string + */ +fun ByteArray.encodeToBase58String(): String { + val input = copyOf(size) // since we modify it in-place + if (input.isEmpty()) { + return "" + } + // Count leading zeros. + var zeros = 0 + while (zeros < input.size && input[zeros].toInt() == 0) { + ++zeros + } + // Convert base-256 digits to base-58 digits (plus conversion to ASCII characters) + val encoded = CharArray(input.size * 2) // upper bound + var outputStart = encoded.size + var inputStart = zeros + while (inputStart < input.size) { + encoded[--outputStart] = alphabet[divmod(input, inputStart.toUInt(), 256.toUInt(), 58.toUInt()).toInt()] + if (input[inputStart].toInt() == 0) { + ++inputStart // optimization - skip leading zeros + } + } + // Preserve exactly as many leading encoded zeros in output as there were leading zeros in data. + while (outputStart < encoded.size && encoded[outputStart] == ENCODED_ZERO) { + ++outputStart + } + while (--zeros >= 0) { + encoded[--outputStart] = ENCODED_ZERO + } + // Return encoded string (including encoded leading zeros). + return String(encoded, outputStart, encoded.size - outputStart) +} + +/** + * Decodes the base58 string into a [ByteArray] + * + * @return the decoded data bytes + * @throws NumberFormatException if the string is not a valid base58 string + */ +@Throws(NumberFormatException::class) +fun String.decodeBase58(): ByteArray { + if (isEmpty()) { + return ByteArray(0) + } + // Convert the base58-encoded ASCII chars to a base58 byte sequence (base58 digits). + val input58 = ByteArray(length) + for (i in 0 until length) { + val c = this[i] + val digit = if (c.toInt() < 128) alphabetIndices[c.toInt()] else -1 + if (digit < 0) { + throw NumberFormatException("Illegal character $c at position $i") + } + input58[i] = digit.toByte() + } + // Count leading zeros. + var zeros = 0 + while (zeros < input58.size && input58[zeros].toInt() == 0) { + ++zeros + } + // Convert base-58 digits to base-256 digits. + val decoded = ByteArray(length) + var outputStart = decoded.size + var inputStart = zeros + while (inputStart < input58.size) { + decoded[--outputStart] = divmod(input58, inputStart.toUInt(), 58.toUInt(), 256.toUInt()).toByte() + if (input58[inputStart].toInt() == 0) { + ++inputStart // optimization - skip leading zeros + } + } + // Ignore extra leading zeroes that were added during the calculation. + while (outputStart < decoded.size && decoded[outputStart].toInt() == 0) { + ++outputStart + } + // Return decoded data (including original number of leading zeros). + return decoded.copyOfRange(outputStart - zeros, decoded.size) +} + +/** + * Divides a number, represented as an array of bytes each containing a single digit + * in the specified base, by the given divisor. The given number is modified in-place + * to contain the quotient, and the return value is the remainder. + * + * @param number the number to divide + * @param firstDigit the index within the array of the first non-zero digit + * (this is used for optimization by skipping the leading zeros) + * @param base the base in which the number's digits are represented (up to 256) + * @param divisor the number to divide by (up to 256) + * @return the remainder of the division operation + */ +private fun divmod(number: ByteArray, firstDigit: UInt, base: UInt, divisor: UInt): UInt { + // this is just long division which accounts for the base of the input digits + var remainder = 0.toUInt() + for (i in firstDigit until number.size.toUInt()) { + val digit = number[i.toInt()].toUByte() + val temp = remainder * base + digit + number[i.toInt()] = (temp / divisor).toByte() + remainder = temp % divisor + } + return remainder +} + +/** + * Encodes the given bytes as a base58 string, a checksum is appended + * + * @return the base58-encoded string + */ +fun ByteArray.encodeToBase58WithChecksum() = ByteArray(size + CHECKSUM_SIZE).apply { + System.arraycopy(this@encodeToBase58WithChecksum, 0, this, 0, this@encodeToBase58WithChecksum.size) + val checksum = this@encodeToBase58WithChecksum.sha256().sha256() + System.arraycopy(checksum, 0, this, this@encodeToBase58WithChecksum.size, CHECKSUM_SIZE) +}.encodeToBase58String() + +fun String.decodeBase58WithChecksum(): ByteArray { + val rawBytes = decodeBase58() + if (rawBytes.size < CHECKSUM_SIZE) { + throw Exception("Too short for checksum: $this l: ${rawBytes.size}") + } + val checksum = rawBytes.copyOfRange(rawBytes.size - CHECKSUM_SIZE, rawBytes.size) + + val payload = rawBytes.copyOfRange(0, rawBytes.size - CHECKSUM_SIZE) + + val hash = payload.sha256().sha256() + val computedChecksum = hash.copyOfRange(0, CHECKSUM_SIZE) + + if (checksum.contentEquals(computedChecksum)) { + return payload + } else { + throw IllegalArgumentException("Checksum mismatch: $checksum is not computed checksum $computedChecksum") + } +} diff --git a/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt b/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt index c0026be..f29ac9d 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt @@ -11,6 +11,7 @@ import exchange.dydx.cartera.entities.Wallet import exchange.dydx.cartera.walletprovider.WalletOperationProviderProtocol import exchange.dydx.cartera.walletprovider.WalletUserConsentProtocol import exchange.dydx.cartera.walletprovider.providers.MagicLinkProvider +import exchange.dydx.cartera.walletprovider.providers.PhantomWalletProvider import exchange.dydx.cartera.walletprovider.providers.WalletConnectModalProvider import exchange.dydx.cartera.walletprovider.providers.WalletConnectV1Provider import exchange.dydx.cartera.walletprovider.providers.WalletConnectV2Provider @@ -23,6 +24,7 @@ sealed class WalletConnectionType(val rawValue: String) { object WalletConnectModal : WalletConnectionType("walletConnectModal") object WalletSegue : WalletConnectionType("walletSegue") object MagicLink : WalletConnectionType("magicLink") + object PhantomWallet : WalletConnectionType("phantomWallet") class Custom(val value: String) : WalletConnectionType(value) object Unknown : WalletConnectionType("unknown") @@ -34,6 +36,7 @@ sealed class WalletConnectionType(val rawValue: String) { WalletConnectModal.rawValue -> WalletConnectModal WalletSegue.rawValue -> WalletSegue MagicLink.rawValue -> MagicLink + PhantomWallet.rawValue -> PhantomWallet else -> Custom(rawValue) } } @@ -49,9 +52,10 @@ class CarteraConfig( var shared: CarteraConfig? = null fun handleResponse(url: Uri): Boolean { - shared?.registration?.get(WalletConnectionType.WalletSegue)?.provider?.let { provider -> - val walletSegueProvider = provider as? WalletSegueProvider - return walletSegueProvider?.handleResponse(url) ?: false + shared?.registration?.values?.forEach { + if (it.provider.handleResponse(url)) { + return@handleResponse true + } } return false } @@ -83,6 +87,14 @@ class CarteraConfig( ), ) } + if (walletProvidersConfig.phantomWallet != null) { + registration[WalletConnectionType.PhantomWallet] = RegistrationConfig( + provider = PhantomWalletProvider( + phantomWalletConfig = walletProvidersConfig.phantomWallet, + application = application, + ), + ) + } registration[WalletConnectionType.MagicLink] = RegistrationConfig( provider = MagicLinkProvider(), ) @@ -142,7 +154,8 @@ data class WalletProvidersConfig( val walletConnectV1: WalletConnectV1Config? = null, val walletConnectV2: WalletConnectV2Config? = null, val walletConnectModal: WalletConnectModalConfig? = null, - val walletSegue: WalletSegueConfig? = null + val walletSegue: WalletSegueConfig? = null, + val phantomWallet: PhantomWalletConfig? = null, ) data class WalletConnectV1Config( @@ -193,3 +206,8 @@ data class WalletConnectModalConfig( data class WalletSegueConfig( val callbackUrl: String ) + +data class PhantomWalletConfig( + val callbackUrl: String, + val appUrl: String +) diff --git a/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt index 0e4e1f5..43d477e 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt @@ -1,6 +1,7 @@ package exchange.dydx.cartera import android.content.Context +import android.net.Uri import exchange.dydx.cartera.entities.connectionType import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol import exchange.dydx.cartera.walletprovider.EthereumAddChainRequest @@ -28,6 +29,10 @@ class CarteraProvider(private val context: Context) : WalletOperationProviderPro currentRequestHandler?.connect(WalletRequest(null, null, chainId, context), completion) } + override fun handleResponse(uri: Uri): Boolean { + return currentRequestHandler?.handleResponse(uri) ?: false + } + // WalletOperationProviderProtocol override val walletStatus: WalletStatusProtocol? @@ -112,7 +117,7 @@ class CarteraProvider(private val context: Context) : WalletOperationProviderPro val provider = if (request.useModal) { CarteraConfig.shared?.getProvider(WalletConnectionType.WalletConnectModal) } else { - request.wallet?.config?.connectionType(context)?.let { + request.wallet?.config?.connectionType()?.let { CarteraConfig.shared?.getProvider(it) } } @@ -136,7 +141,7 @@ class CarteraProvider(private val context: Context) : WalletOperationProviderPro } private fun getUserActionDelegate(request: WalletRequest): WalletUserConsentProtocol { - val connectionType = request.wallet?.config?.connectionType(context) + val connectionType = request.wallet?.config?.connectionType() val userConsentHandler = if (connectionType != null) { CarteraConfig.shared?.getUserConsentHandler(connectionType) } else { diff --git a/cartera/src/main/java/exchange/dydx/cartera/entities/ModelExtensions.kt b/cartera/src/main/java/exchange/dydx/cartera/entities/ModelExtensions.kt index eaf1f91..d3f5c37 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/entities/ModelExtensions.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/entities/ModelExtensions.kt @@ -46,7 +46,7 @@ val WalletConfig.iosEnabled: Boolean return false } -fun WalletConfig.connectionType(context: Context): WalletConnectionType { +fun WalletConfig.connectionType(): WalletConnectionType { connections.firstOrNull()?.type?.let { type -> return WalletConnectionType.fromRawValue(type) } diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt index 655b576..fb378e7 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt @@ -1,6 +1,7 @@ package exchange.dydx.cartera.walletprovider import android.content.Context +import android.net.Uri import exchange.dydx.cartera.entities.Wallet import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol import java.math.BigInteger @@ -23,8 +24,29 @@ data class WalletRequest( data class WalletTransactionRequest( val walletRequest: WalletRequest, - val ethereum: EthereumTransactionRequest? -) + val ethereum: EthereumTransactionRequest?, + val solana: ByteArray? +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WalletTransactionRequest + + if (walletRequest != other.walletRequest) return false + if (ethereum != other.ethereum) return false + if (!solana.contentEquals(other.solana)) return false + + return true + } + + override fun hashCode(): Int { + var result = walletRequest.hashCode() + result = 31 * result + (ethereum?.hashCode() ?: 0) + result = 31 * result + (solana?.contentHashCode() ?: 0) + return result + } +} data class EthereumTransactionRequest( val fromAddress: String, @@ -61,4 +83,8 @@ interface WalletUserConsentOperationProtocol : WalletOperationProtocol { var userConsentDelegate: WalletUserConsentProtocol? } -interface WalletOperationProviderProtocol : WalletStatusProviding, WalletUserConsentOperationProtocol +interface WalletDeeplinkHandlingProtocol { + fun handleResponse(uri: Uri): Boolean +} + +interface WalletOperationProviderProtocol : WalletStatusProviding, WalletUserConsentOperationProtocol, WalletDeeplinkHandlingProtocol diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/MagicLinkProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/MagicLinkProvider.kt index 082ac62..71b7a1b 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/MagicLinkProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/MagicLinkProvider.kt @@ -1,5 +1,6 @@ package exchange.dydx.cartera.walletprovider.providers +import android.net.Uri import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol import exchange.dydx.cartera.walletprovider.EthereumAddChainRequest import exchange.dydx.cartera.walletprovider.WalletConnectCompletion @@ -26,6 +27,11 @@ class MagicLinkProvider : WalletOperationProviderProtocol { override var walletStatusDelegate: WalletStatusDelegate? = null override var userConsentDelegate: WalletUserConsentProtocol? = null + + override fun handleResponse(uri: Uri): Boolean { + return false + } + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { } 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 new file mode 100644 index 0000000..b4375ba --- /dev/null +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -0,0 +1,525 @@ +package exchange.dydx.cartera.walletprovider.providers + +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.iwebpp.crypto.TweetNaclFast +import exchange.dydx.cartera.CarteraErrorCode +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.tag +import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol +import exchange.dydx.cartera.typeddata.typedDataAsString +import exchange.dydx.cartera.walletprovider.EthereumAddChainRequest +import exchange.dydx.cartera.walletprovider.WalletConnectCompletion +import exchange.dydx.cartera.walletprovider.WalletConnectedCompletion +import exchange.dydx.cartera.walletprovider.WalletError +import exchange.dydx.cartera.walletprovider.WalletInfo +import exchange.dydx.cartera.walletprovider.WalletOperationCompletion +import exchange.dydx.cartera.walletprovider.WalletOperationProviderProtocol +import exchange.dydx.cartera.walletprovider.WalletOperationStatus +import exchange.dydx.cartera.walletprovider.WalletRequest +import exchange.dydx.cartera.walletprovider.WalletState +import exchange.dydx.cartera.walletprovider.WalletStatusDelegate +import exchange.dydx.cartera.walletprovider.WalletStatusImp +import exchange.dydx.cartera.walletprovider.WalletStatusProtocol +import exchange.dydx.cartera.walletprovider.WalletTransactionRequest +import exchange.dydx.cartera.walletprovider.WalletUserConsentProtocol +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.random.Random + +class PhantomWalletProvider( + private val phantomWalletConfig: PhantomWalletConfig, + private val application: Application, +) : WalletOperationProviderProtocol { + + private enum class CallbackAction(val request: String) { + onConnect("connect"), + onDisconnect("disconnect"), + onSignMessage("signMessage"), + onSignTransaction("signTransaction"), + onSendTransaction("signAndSendTransaction") + } + + private var _walletStatus = WalletStatusImp() + set(value) { + field = value + walletStatusDelegate?.statusChanged(value) + } + override val walletStatus: WalletStatusProtocol + get() = _walletStatus + + override var walletStatusDelegate: WalletStatusDelegate? = null + override var userConsentDelegate: WalletUserConsentProtocol? = null + + private val baseUrlString = "https://phantom.app/ul/v1" + + private var publicKey: ByteArray? = null + private var privateKey: ByteArray? = null + private var phantomPublicKey: ByteArray? = null + private var session: String? = null + + private var connectionCompletion: WalletConnectCompletion? = null + private var connectionWallet: Wallet? = null + private var operationCompletion: WalletOperationCompletion? = null + + override fun handleResponse(uri: Uri): Boolean { + if (!uri.toString().startsWith(phantomWalletConfig.callbackUrl)) { + return false + } + + val action = uri.lastPathSegment ?: return false + val errorCode = uri.getQueryParameter("errorCode") + val errorMessage = uri.getQueryParameter("errorMessage") ?: "Unknown error" + + when (action) { + CallbackAction.onConnect.name -> { + if (connectionCompletion != null) { + if (errorCode != null) { + CoroutineScope(Dispatchers.Main).launch { + connectionCompletion?.invoke(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, errorMessage)) + connectionCompletion = null + } + } else { + val encodedPublicKey = + uri.getQueryParameter("phantom_encryption_public_key") + phantomPublicKey = encodedPublicKey?.decodeBase58() + + val data = decryptPayload( + payload = uri.getQueryParameter("data"), + nonce = uri.getQueryParameter("nonce"), + ) + val response = try { + Gson().fromJson(data?.decodeToString(), ConnectResponse::class.java) + } catch (e: Exception) { + null + } + if (response != null) { + session = response.session + val walletInfo = WalletInfo( + address = response.publicKey, + chainId = null, + wallet = connectionWallet, + ) + _walletStatus.state = WalletState.CONNECTED_TO_WALLET + _walletStatus.connectedWallet = walletInfo + CoroutineScope(Dispatchers.Main).launch { + connectionCompletion?.invoke(walletInfo, null) + connectionCompletion = null + } + } else { + CoroutineScope(Dispatchers.Main).launch { + connectionCompletion?.invoke(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to decrypt payload")) + connectionCompletion = null + } + } + } + } + } + + CallbackAction.onDisconnect.name -> { + if (errorCode != null) { + Timber.tag(tag(this@PhantomWalletProvider)).d("Disconnected Error: $errorMessage, $errorCode") + } + } + + CallbackAction.onSignMessage.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(), SignMessageResponse::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 + } + } + } + } + } + + CallbackAction.onSignTransaction.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(), SignTransactionResponse::class.java) + } catch (e: Exception) { + null + } + if (response != null) { + CoroutineScope(Dispatchers.Main).launch { + operationCompletion?.invoke(response.transaction, null) + operationCompletion = null + } + } else { + CoroutineScope(Dispatchers.Main).launch { + operationCompletion?.invoke(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to decrypt payload")) + operationCompletion = null + } + } + } + } + } + + 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 + } + + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { + if (_walletStatus.state == WalletState.CONNECTED_TO_WALLET) { + completion(walletStatus.connectedWallet, null) + return + } + + val result = TweetNaclFast.Box.keyPair() + if (result == null) { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to generate key pair")) + return + } + + publicKey = result.publicKey + privateKey = result.secretKey + + val publickKeyEncoded = publicKey?.encodeToBase58String() + if (publickKeyEncoded == null) { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to encode public key")) + return + } + + val cluster = if (request.chainId == "1") { + "mainnet-beta" + } else { + "devnet" + } + if (cluster == null) { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to get chainId")) + return + } + + try { + val uri = "$baseUrlString/${CallbackAction.onConnect.request}".toUri() + .buildUpon() + .appendQueryParameter("dapp_encryption_public_key", publickKeyEncoded) + .appendQueryParameter("cluster", cluster) + .appendQueryParameter("app_url", phantomWalletConfig.appUrl) + .appendQueryParameter("redirect_link", "${phantomWalletConfig.callbackUrl}/${CallbackAction.onConnect}") + .build() + + if (openPeerDeeplink(uri)) { + connectionCompletion = completion + connectionWallet = request.wallet + } else { + completion( + null, + WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app"), + ) + } + } catch (e: Exception) { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, e.message ?: "Unknown error")) + } + } + + override fun disconnect() { + publicKey = null + privateKey = null + phantomPublicKey = null + connectionCompletion = null + operationCompletion = null + + session = null + connectionWallet = null + _walletStatus.state = WalletState.IDLE + _walletStatus.connectedWallet = null + _walletStatus.connectionDeeplink = null + } + + override fun signMessage( + request: WalletRequest, + message: String, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + connect(request = request) { info, error -> + if (error != null) { + completion(null, error) + } else { + connected?.invoke(info) + doSignMessage(message, completion) + } + } + } + + private fun doSignMessage( + message: String, + completion: WalletOperationCompletion + ) { + val request = SignMessageRequest( + session = session, + message = message.toByteArray().encodeToBase58String(), + display = "utf8", + ) + val uri = createRequestUri( + request = Gson().toJson(request), + action = CallbackAction.onSignMessage, + ) + 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 sign( + request: WalletRequest, + typedDataProvider: WalletTypedDataProviderProtocol?, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + val message = typedDataProvider?.typedDataAsString + if (message == null) { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Typed data is null")) + return + } + signMessage( + request = request, + message = message, + connected = connected, + status = status, + completion = completion, + ) + } + + override fun send( + request: WalletTransactionRequest, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + connect(request = request.walletRequest) { info, error -> + if (error != null) { + completion(null, error) + } else { + connected?.invoke(info) + doSend(request, completion) + } + } + } + + private fun doSend( + request: WalletTransactionRequest, + completion: WalletOperationCompletion + ) { + val data = request.solana + if (data == null) { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Solana transaction data is null")) + 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")) + } + } else { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to create request URI")) + } + } + + override fun addChain( + request: WalletRequest, + chain: EthereumAddChainRequest, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + TODO("Not yet implemented") + } + + private fun openPeerDeeplink(uri: Uri): Boolean { + try { + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application.startActivity(intent) + return true + } catch (exception: ActivityNotFoundException) { + Timber.tag(tag(this@PhantomWalletProvider)).d("There is no app to handle deep link") + return false + } + } + + private fun decryptPayload(payload: String?, nonce: String?): ByteArray? { + val decodedData = payload?.decodeBase58() ?: return null + val decodedNonceData = nonce?.decodeBase58() ?: return null + val publicKey = phantomPublicKey ?: return null + val privateKey = privateKey ?: return null + + val box = TweetNaclFast.Box(publicKey, privateKey) + return box.open(decodedData, decodedNonceData) + } + + private fun encryptPayload(payload: ByteArray?): Pair? { + val payload = payload ?: return null + val publicKey = phantomPublicKey ?: return null + val privateKey = privateKey ?: return null + val nonceData: ByteArray = generateRandomBytes(length = 24) + + val box = TweetNaclFast.Box(publicKey, privateKey) + val encryptedData = box.box(payload, nonceData) + return Pair(encryptedData, nonceData) + } + + private fun generateRandomBytes(length: Int): ByteArray { + return Random.Default.nextBytes(length) + } + + private fun createRequestUri(request: String?, action: CallbackAction): Uri? { + val result = encryptPayload(request?.toByteArray()) ?: return null + val publicKey = publicKey ?: return null + val payload = result.first + val nonce = result.second + try { + val uri = "$baseUrlString/${action.request}".toUri() + .buildUpon() + .appendQueryParameter("payload", payload.encodeToBase58String()) + .appendQueryParameter("nonce", nonce.encodeToBase58String()) + .appendQueryParameter( + "redirect_link", + "${phantomWalletConfig.callbackUrl}/${action.name}", + ) + .appendQueryParameter( + "dapp_encryption_public_key", + publicKey.encodeToBase58String(), + ) + .build() + return uri + } catch (e: Exception) { + return null + } + } +} + +data class ConnectResponse( + @SerializedName("public_key") val publicKey: String?, + @SerializedName("session") val session: String? +) + +data class DisconnectRequest( + @SerializedName("session") val session: String? +) + +data class SignMessageRequest( + @SerializedName("session") val session: String?, + @SerializedName("message") val message: String?, + @SerializedName("display") val display: String? // "utf8" | "hex" +) + +data class SignMessageResponse( + @SerializedName("signature") val signature: String? +) + +data class SignTransactionRequest( + @SerializedName("session") val session: String?, + @SerializedName("transaction") val transaction: String? +) + +data class SignTransactionResponse( + @SerializedName("transaction") val transaction: String? +) + +data class SendTransactionRequest( + @SerializedName("session") val session: String?, + @SerializedName("transaction") val transaction: String? +) + +data class SendTransactionResponse( + @SerializedName("signature") val signature: String? +) + +data class PhantomSession( + @SerializedName("app_url") val appUrl: String?, + @SerializedName("timestamp") val timestamp: String?, + @SerializedName("chain") val chain: String?, + @SerializedName("cluster") val cluster: String? +) diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectModalProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectModalProvider.kt index cadce66..35bd434 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectModalProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectModalProvider.kt @@ -85,8 +85,8 @@ class WalletConnectModalProvider( WalletConnectModal.initialize( init = Modal.Params.Init( core = CoreClient, - recommendedWalletsIds = config?.walletIds ?: emptyList(), - excludedWalletIds = excludedIds, + // recommendedWalletsIds = config?.walletIds ?: emptyList(), + // excludedWalletIds = excludedIds, ), onSuccess = { // Callback will be called if initialization is successful @@ -100,6 +100,10 @@ class WalletConnectModalProvider( ) } + override fun handleResponse(uri: Uri): Boolean { + return false + } + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { if (_walletStatus.state == WalletState.CONNECTED_TO_WALLET) { completion(walletStatus.connectedWallet, null) diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV1Provider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV1Provider.kt index 648ccee..8bfefa7 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV1Provider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV1Provider.kt @@ -1,5 +1,6 @@ package exchange.dydx.cartera.walletprovider.providers +import android.net.Uri import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol import exchange.dydx.cartera.walletprovider.EthereumAddChainRequest import exchange.dydx.cartera.walletprovider.WalletConnectCompletion @@ -27,6 +28,10 @@ class WalletConnectV1Provider : WalletOperationProviderProtocol { override var walletStatusDelegate: WalletStatusDelegate? = null override var userConsentDelegate: WalletUserConsentProtocol? = null + override fun handleResponse(uri: Uri): Boolean { + return false + } + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { } diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV2Provider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV2Provider.kt index 63f0dbe..2791e57 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV2Provider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletConnectV2Provider.kt @@ -3,6 +3,7 @@ package exchange.dydx.cartera.walletprovider.providers import android.app.Application import android.content.ActivityNotFoundException import android.content.Intent +import android.net.Uri import android.util.Log import com.walletconnect.android.Core import com.walletconnect.android.CoreClient @@ -277,6 +278,10 @@ class WalletConnectV2Provider( } } + override fun handleResponse(uri: Uri): Boolean { + return false + } + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { if (_walletStatus.state == WalletState.CONNECTED_TO_WALLET) { completion(walletStatus.connectedWallet, null) diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletSegueProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletSegueProvider.kt index 12a156f..11cb1af 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletSegueProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/WalletSegueProvider.kt @@ -65,8 +65,12 @@ class WalletSegueProvider( } } + override fun handleResponse(uri: Uri): Boolean { + return client?.handleResponse(uri) ?: false + } + override fun connect(request: WalletRequest, completion: WalletConnectCompletion) { - if (walletStatus?.connectedWallet == null || client?.isConnected ?: false == false) { + if (walletStatus?.connectedWallet == null || (client?.isConnected ?: false) == false) { _walletStatus.state = WalletState.IDLE } val wallet = request.wallet @@ -210,10 +214,6 @@ class WalletSegueProvider( TODO("Not yet implemented") } - fun handleResponse(url: Uri): Boolean { - return client?.handleResponse(url) ?: false - } - private fun doSign( request: WalletRequest, action: Action, diff --git a/cartera/src/main/res/raw/wallets_config.json b/cartera/src/main/res/raw/wallets_config.json index e29647f..731d70b 100644 --- a/cartera/src/main/res/raw/wallets_config.json +++ b/cartera/src/main/res/raw/wallets_config.json @@ -98,6 +98,61 @@ ] } }, + { + "id": "phantom-wallet", + "name": "Phantom Wallet", + "description": "", + "homepage": "https://phantom.com/", + "chains": [ + "solana" + ], + "versions": [ + "1" + ], + "app": { + "browser": "", + "ios": "https://apps.apple.com/us/app/phantom-crypto-wallet/id1598432977", + "android": "https://play.google.com/store/apps/details?id=app.phantom", + "mac": "", + "windows": "", + "linux": "" + }, + "mobile": { + "native": "phantom:", + "universal": "" + }, + "desktop": { + "native": "", + "universal": "" + }, + "metadata": { + "shortName": "Phantom", + "colors": { + "primary": "rgb(255, 255, 255)", + "secondary": "" + } + }, + "config": { + "comment": "Phantom", + "iosMinVersion": "1.8.0", + "imageUrl": "https://s3.amazonaws.com/dydx.exchange/logos/wallets/phantom.png", + "androidPackage": "app.phantom", + "encoding": "=\"#%/<>?@\\^`{|}:&", + "connections": [ + { + "type": "phantomWallet", + "native": "phantom:", + "universal": "" + } + ], + "methods": [ + "eth_sendTransaction", + "personal_sign", + "eth_signTypedData", + "wallet_addEthereumChain" + ] + } + }, { "id": "9d373b43ad4d2cf190fb1a774ec964a1addf406d6fd24af94ab7596e58c291b2", "name": "imToken", diff --git a/gradle.properties b/gradle.properties index 3846055..032c8fc 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.20 +LIBRARY_VERSION_NAME=0.1.21 android.enableR8.fullMode = false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9e1bdd2..1604b78 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Jun 12 15:23:49 PDT 2024 +#Sat Mar 22 10:48:06 PDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 51b4fb1..d98eacb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,6 +16,14 @@ dependencyResolutionManagement { maven { url "https://jitpack.io" } + maven { + name = "komputing/KHash GitHub Packages" + url = uri("https://maven.pkg.github.com/komputing/KHash") + credentials { + username = "token" + password = "\u0039\u0032\u0037\u0034\u0031\u0064\u0038\u0033\u0064\u0036\u0039\u0061\u0063\u0061\u0066\u0031\u0062\u0034\u0061\u0030\u0034\u0035\u0033\u0061\u0063\u0032\u0036\u0038\u0036\u0062\u0036\u0032\u0035\u0065\u0034\u0061\u0065\u0034\u0032\u0062" + } + } } } rootProject.name = "CarteraExample"