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"