From 26824cb2df0f45e64dbd66b178cd0eea0983540b Mon Sep 17 00:00:00 2001 From: Rui Date: Wed, 19 Mar 2025 13:50:29 -0700 Subject: [PATCH 1/5] WIP --- .../WalletProvidersConfigUtil.kt | 6 + cartera/build.gradle | 8 + .../main/java/exchange/dydx/cartera/Base58.kt | 162 ++++++++++++++++ .../exchange/dydx/cartera/CarteraConfig.kt | 26 ++- .../exchange/dydx/cartera/CarteraProvider.kt | 5 + .../walletprovider/WalletProviderProtocols.kt | 7 +- .../providers/MagicLinkProvider.kt | 6 + .../providers/PhantomWalletProvider.kt | 174 ++++++++++++++++++ .../providers/WalletConnectModalProvider.kt | 8 +- .../providers/WalletConnectV1Provider.kt | 5 + .../providers/WalletConnectV2Provider.kt | 5 + .../providers/WalletSegueProvider.kt | 8 +- cartera/src/main/res/raw/wallets_config.json | 55 ++++++ gradle.properties | 2 +- settings.gradle | 8 + 15 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 cartera/src/main/java/exchange/dydx/cartera/Base58.kt create mode 100644 cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt index ba387b8..51a349e 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 @@ -29,11 +30,16 @@ object WalletProvidersConfigUtil { callbackUrl = "https://trade.stage.dydx.exchange/walletsegueCarteraExample", ) + val phantomWalletConfig = PhantomWalletConfig( + callbackUrl = "https://v4-web-internal.vercel.app/phantomCarteraExample", + appUrl = "https://v4.testnet.dydx.exchange/", + ) return WalletProvidersConfig( walletConnectV1 = walletConnectV1Config, walletConnectV2 = walletConnectV2Config, walletConnectModal = WalletConnectModalConfig.default, walletSegue = walletSegueConfig, + phantom = phantomWalletConfig, ) } } diff --git a/cartera/build.gradle b/cartera/build.gradle index 50627a7..a3f410e 100644 --- a/cartera/build.gradle +++ b/cartera/build.gradle @@ -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..d1945a5 --- /dev/null +++ b/cartera/src/main/java/exchange/dydx/cartera/Base58.kt @@ -0,0 +1,162 @@ +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") + } +} \ No newline at end of file diff --git a/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt b/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt index c0026be..2bfcbd0 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 Phantom : WalletConnectionType("phantom") 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 + Phantom.rawValue -> Phantom 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.phantom != null) { + registration[WalletConnectionType.Phantom] = RegistrationConfig( + provider = PhantomWalletProvider( + phantomWalletConfig = walletProvidersConfig.phantom, + 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 phantom: 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..169ebc9 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? 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..408f412 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 @@ -61,4 +62,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..a205334 --- /dev/null +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -0,0 +1,174 @@ +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.iwebpp.crypto.TweetNaclFast +import exchange.dydx.cartera.CarteraErrorCode +import exchange.dydx.cartera.PhantomWalletConfig +import exchange.dydx.cartera.encodeToBase58String +import exchange.dydx.cartera.tag +import exchange.dydx.cartera.typeddata.WalletTypedDataProviderProtocol +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.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 timber.log.Timber +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +class PhantomWalletProvider( + private val phantomWalletConfig: PhantomWalletConfig, + private val application: Application, +): WalletOperationProviderProtocol { + + private enum class CallbackAction(val request: String) { + ON_CONNECT("connect"), + ON_DISCONNECT("disconnect"), + ON_SIGN_MESSAGE("signMessage"), + ON_SIGN_TRANSACTION("signTransaction"), + ON_SEND_TRANSACTION("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 + + override fun handleResponse(uri: Uri): Boolean { + 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 url = "$baseUrlString/${CallbackAction.ON_CONNECT}" + val cluster = request.chainId + if (cluster == null) { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to get chainId")) + return + } + + val appUrl = phantomWalletConfig.appUrl.urlEncoded() + val redirectLink = "${phantomWalletConfig.callbackUrl}/${CallbackAction.ON_CONNECT}".urlEncoded() + val urlQueryParams = mapOf( + "dapp_encryption_public_key" to publickKeyEncoded, + "cluster" to cluster, + "app_url" to appUrl, + "redirect_link" to redirectLink, + ) + .map { "${it.key}=${it.value}" }.joinToString("&") + + val requestUrl = "$url?$urlQueryParams" + if (openPeerDeeplink(requestUrl)) { + // TODO + } else { + completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app")) + } + + } + + override fun disconnect() { + TODO("Not yet implemented") + } + + override fun signMessage( + request: WalletRequest, + message: String, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + TODO("Not yet implemented") + } + + override fun sign( + request: WalletRequest, + typedDataProvider: WalletTypedDataProviderProtocol?, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + TODO("Not yet implemented") + } + + override fun send( + request: WalletTransactionRequest, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + TODO("Not yet implemented") + } + + override fun addChain( + request: WalletRequest, + chain: EthereumAddChainRequest, + connected: WalletConnectedCompletion?, + status: WalletOperationStatus?, + completion: WalletOperationCompletion + ) { + TODO("Not yet implemented") + } + + private fun openPeerDeeplink(url: String): Boolean { + val uri = url.toUri() + 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 + } + } + +} + +fun String.urlEncoded(): String { + return URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) +} \ No newline at end of file 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..a19e6d9 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..c800bbd 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,6 +65,10 @@ 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) { _walletStatus.state = WalletState.IDLE @@ -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..4e43340 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": [ + "eip155:1" + ], + "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..16d029a 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=local.1742406510 android.enableR8.fullMode = false \ No newline at end of file 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" From 4ab1858f9094a4408403914860d3303167541ea7 Mon Sep 17 00:00:00 2001 From: Rui Date: Fri, 21 Mar 2025 21:28:10 -0700 Subject: [PATCH 2/5] Connect and SignMessage --- app/src/main/AndroidManifest.xml | 23 +- .../dydx/carteraExample/MainActivity.kt | 22 ++ .../carteraExample/WalletListViewModel.kt | 14 +- .../WalletProvidersConfigUtil.kt | 6 +- .../exchange/dydx/cartera/CarteraConfig.kt | 12 +- .../providers/PhantomWalletProvider.kt | 288 ++++++++++++++++-- .../providers/WalletSegueProvider.kt | 2 +- cartera/src/main/res/raw/wallets_config.json | 2 +- 8 files changed, 332 insertions(+), 37 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64c0902..6769007 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 +54,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..56da4bf 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,8 +81,22 @@ 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 fun MyApp(content: @Composable () -> Unit) { MaterialTheme { diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 87b4ca0..9665a03 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt @@ -40,7 +40,19 @@ 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) { + if (wallet?.id == "phantom-wallet") { + "devnet" + } else { + CarteraConstants.testnetChainId + } + } else { + if (wallet?.id == "phantom-wallet") { + "mainnet-beta" + } else { + "1" + } + } when (action) { WalletList.WalletAction.Connect -> { testConnect(wallet, chainId, useModal) diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt index 51a349e..d7bf0b9 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletProvidersConfigUtil.kt @@ -27,19 +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.testnet.dydx.exchange/", + appUrl = "https://v4-web-internal.vercel.app", ) return WalletProvidersConfig( walletConnectV1 = walletConnectV1Config, walletConnectV2 = walletConnectV2Config, walletConnectModal = WalletConnectModalConfig.default, walletSegue = walletSegueConfig, - phantom = phantomWalletConfig, + phantomWallet = phantomWalletConfig, ) } } diff --git a/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt b/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt index 2bfcbd0..f29ac9d 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/CarteraConfig.kt @@ -24,7 +24,7 @@ sealed class WalletConnectionType(val rawValue: String) { object WalletConnectModal : WalletConnectionType("walletConnectModal") object WalletSegue : WalletConnectionType("walletSegue") object MagicLink : WalletConnectionType("magicLink") - object Phantom : WalletConnectionType("phantom") + object PhantomWallet : WalletConnectionType("phantomWallet") class Custom(val value: String) : WalletConnectionType(value) object Unknown : WalletConnectionType("unknown") @@ -36,7 +36,7 @@ sealed class WalletConnectionType(val rawValue: String) { WalletConnectModal.rawValue -> WalletConnectModal WalletSegue.rawValue -> WalletSegue MagicLink.rawValue -> MagicLink - Phantom.rawValue -> Phantom + PhantomWallet.rawValue -> PhantomWallet else -> Custom(rawValue) } } @@ -87,10 +87,10 @@ class CarteraConfig( ), ) } - if (walletProvidersConfig.phantom != null) { - registration[WalletConnectionType.Phantom] = RegistrationConfig( + if (walletProvidersConfig.phantomWallet != null) { + registration[WalletConnectionType.PhantomWallet] = RegistrationConfig( provider = PhantomWalletProvider( - phantomWalletConfig = walletProvidersConfig.phantom, + phantomWalletConfig = walletProvidersConfig.phantomWallet, application = application, ), ) @@ -155,7 +155,7 @@ data class WalletProvidersConfig( val walletConnectV2: WalletConnectV2Config? = null, val walletConnectModal: WalletConnectModalConfig? = null, val walletSegue: WalletSegueConfig? = null, - val phantom: PhantomWalletConfig? = null, + val phantomWallet: PhantomWalletConfig? = null, ) data class WalletConnectV1Config( diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt index a205334..feaac17 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -5,16 +5,21 @@ 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.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 @@ -25,9 +30,13 @@ 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 java.net.URLEncoder import java.nio.charset.StandardCharsets +import kotlin.random.Random class PhantomWalletProvider( private val phantomWalletConfig: PhantomWalletConfig, @@ -35,11 +44,11 @@ class PhantomWalletProvider( ): WalletOperationProviderProtocol { private enum class CallbackAction(val request: String) { - ON_CONNECT("connect"), - ON_DISCONNECT("disconnect"), - ON_SIGN_MESSAGE("signMessage"), - ON_SIGN_TRANSACTION("signTransaction"), - ON_SEND_TRANSACTION("signAndSendTransaction") + onConnect("connect"), + onDisconnect("disconnect"), + onSignMessage("signMessage"), + onSignTransaction("signTransaction"), + onSendTransaction("signAndSendTransaction") } private var _walletStatus = WalletStatusImp() @@ -60,7 +69,111 @@ class PhantomWalletProvider( 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 -> { + + } + + CallbackAction.onSendTransaction.name -> { + + } + } + return true } @@ -85,7 +198,7 @@ class PhantomWalletProvider( return } - val url = "$baseUrlString/${CallbackAction.ON_CONNECT}" + val url = "$baseUrlString/${CallbackAction.onConnect.request}" val cluster = request.chainId if (cluster == null) { completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to get chainId")) @@ -93,7 +206,7 @@ class PhantomWalletProvider( } val appUrl = phantomWalletConfig.appUrl.urlEncoded() - val redirectLink = "${phantomWalletConfig.callbackUrl}/${CallbackAction.ON_CONNECT}".urlEncoded() + val redirectLink = "${phantomWalletConfig.callbackUrl}/${CallbackAction.onConnect}".urlEncoded() val urlQueryParams = mapOf( "dapp_encryption_public_key" to publickKeyEncoded, "cluster" to cluster, @@ -102,17 +215,33 @@ class PhantomWalletProvider( ) .map { "${it.key}=${it.value}" }.joinToString("&") - val requestUrl = "$url?$urlQueryParams" - if (openPeerDeeplink(requestUrl)) { - // TODO - } else { - completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app")) + try { + val requestUrl = "$url?$urlQueryParams" + if (openPeerDeeplink(requestUrl.toUri())) { + connectionCompletion = completion + } 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() { - TODO("Not yet implemented") + 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( @@ -122,7 +251,38 @@ class PhantomWalletProvider( status: WalletOperationStatus?, completion: WalletOperationCompletion ) { - TODO("Not yet implemented") + 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.CONNECTION_FAILED, "Failed to open Phantom app")) + } + } else { + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to create request URI")) + } } override fun sign( @@ -154,8 +314,7 @@ class PhantomWalletProvider( TODO("Not yet implemented") } - private fun openPeerDeeplink(url: String): Boolean { - val uri = url.toUri() + private fun openPeerDeeplink(uri: Uri): Boolean { try { val intent = Intent(Intent.ACTION_VIEW, uri) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -167,8 +326,101 @@ class PhantomWalletProvider( } } + 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 + } + } } fun String.urlEncoded(): String { return URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) -} \ No newline at end of file +} + +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? +) \ No newline at end of file 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 c800bbd..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 @@ -70,7 +70,7 @@ class WalletSegueProvider( } 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 diff --git a/cartera/src/main/res/raw/wallets_config.json b/cartera/src/main/res/raw/wallets_config.json index 4e43340..731d70b 100644 --- a/cartera/src/main/res/raw/wallets_config.json +++ b/cartera/src/main/res/raw/wallets_config.json @@ -104,7 +104,7 @@ "description": "", "homepage": "https://phantom.com/", "chains": [ - "eip155:1" + "solana" ], "versions": [ "1" From 1e31b9f8ad00147752a96e8b2d049cbeb573bff3 Mon Sep 17 00:00:00 2001 From: Rui Date: Sat, 22 Mar 2025 12:09:39 -0700 Subject: [PATCH 3/5] SendTransaction --- app/build.gradle | 25 +- .../carteraExample/WalletListViewModel.kt | 128 +++++++--- .../carteraExample/solana/SolanaInteractor.kt | 33 +++ .../carteraExample/solana/web3/AccountInfo.kt | 77 ++++++ .../carteraExample/solana/web3/Extensions.kt | 30 +++ .../solana/web3/KtorNetworkDriver.kt | 21 ++ .../solana/web3/SolanaResponseSerializers.kt | 92 +++++++ .../solana/web3/SolanaRpcClient.kt | 216 ++++++++++++++++ .../solana/web3/SolanaRpcRequest.kt | 241 ++++++++++++++++++ .../solana/web3/SolanaRpcResponse.kt | 24 ++ .../solana/web3/TransactionOptions.kt | 49 ++++ build.gradle | 7 +- cartera/build.gradle | 2 +- .../walletprovider/WalletProviderProtocols.kt | 25 +- .../providers/PhantomWalletProvider.kt | 142 +++++++++-- gradle/wrapper/gradle-wrapper.properties | 4 +- 16 files changed, 1040 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt diff --git a/app/build.gradle b/app/build.gradle index ef8f9a1..2891c9e 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,13 @@ 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") } \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 9665a03..847124e 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( @@ -67,7 +73,7 @@ class WalletListViewModel( } WalletList.WalletAction.SignTransaction -> { - testSendTransaction(wallet, chainId, useModal) + testSendTransaction(wallet, chainId, useTestnet, useModal) } WalletList.WalletAction.Disconnect -> { @@ -139,10 +145,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 -> @@ -169,7 +177,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 -> @@ -184,50 +193,93 @@ 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.devnetClient) + } else { + SolanaInteractor(SolanaInteractor.mainnetClient) + } + val scope = CoroutineScope(Dispatchers.Unconfined) + scope.launch { + val response = interactor.getRecentBlockhash() + if (response.result != null) { + val memoInstruction = interactor.buildTestMemoTransaction(address = SolanaPublicKey.from(publicKey), memo = "Hello, Solana!") + val memoTxMessage = Message.Builder() + .addInstruction(memoInstruction) // Pass in instruction from previous step + .setRecentBlockhash(response.result!!.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/solana/SolanaInteractor.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt new file mode 100644 index 0000000..37dee9b --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt @@ -0,0 +1,33 @@ +package exchange.dydx.carteraexample.solana +import com.solana.publickey.SolanaPublicKey +import com.solana.rpc.BlockhashResponse +import com.solana.rpc.SolanaRpcClient +import com.solana.rpccore.Rpc20Response +import com.solana.transaction.AccountMeta +import com.solana.transaction.TransactionInstruction +import exchange.dydx.carteraexample.solana.web3.KtorNetworkDriver + +class SolanaInteractor( + private val client: SolanaRpcClient +) { + companion object { + val mainnetClient = SolanaRpcClient( + "https://api.mainnet-beta.solana.com", + KtorNetworkDriver() + ) + val devnetClient = SolanaRpcClient("https://api.devnet.solana.com", KtorNetworkDriver()) + } + + private val programId = SolanaPublicKey.from("11111111111111111111111111111111") + + suspend fun getRecentBlockhash(): Rpc20Response { + return client.getLatestBlockhash() + } + + fun buildTestMemoTransaction(address: SolanaPublicKey, memo: String) = + TransactionInstruction( + programId = programId, + accounts = listOf(AccountMeta(publicKey = address, isSigner = true, isWritable = true)), + data = memo.encodeToByteArray() + ) +} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt new file mode 100644 index 0000000..5f5c5ce --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt @@ -0,0 +1,77 @@ +package com.solana.rpc + +import com.solana.publickey.SolanaPublicKey +import com.solana.serializers.* +import com.solana.serializers.BorshAsAsEncodedDataArrayDeserializer +import com.solana.serializers.ByteArrayAsEncodedDataArrayDeserializer +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable + +@Serializable +data class AccountInfo( + val data: D?, + val executable: Boolean, + val lamports: ULong, + val owner: SolanaPublicKey, + val rentEpoch: ULong, + val size: ULong? = null +) + +@Serializable +data class AccountInfoWithPublicKey

( + val account: AccountInfo

, + @SerialName("pubkey") val publicKey: String +) + +fun SolanaAccountDeserializer() = + SolanaResponseDeserializer( + AccountInfo.serializer( + ByteArrayAsEncodedDataArrayDeserializer.asSerializer() + ) + ) + +fun SolanaAccountDeserializer(deserializer: DeserializationStrategy) = + SolanaResponseDeserializer( + AccountInfo.serializer( + BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() + ) + ) + +fun MultipleAccountsDeserializer() = + SolanaResponseDeserializer( + ListSerializer( + AccountInfo.serializer( + ByteArrayAsEncodedDataArrayDeserializer.asSerializer() + ).nullable + ) + ) + +fun MultipleAccountsDeserializer(deserializer: DeserializationStrategy) = + SolanaResponseDeserializer( + ListSerializer( + AccountInfo.serializer( + BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() + ).nullable + ) + ) + +fun ProgramAccountsDeserializer() = + SolanaResponseDeserializer( + ListSerializer( + AccountInfoWithPublicKey.serializer( + ByteArrayAsEncodedDataArrayDeserializer.asSerializer() + ) + ) + ) + +fun ProgramAccountsDeserializer(deserializer: DeserializationStrategy) = + SolanaResponseDeserializer( + ListSerializer( + AccountInfoWithPublicKey.serializer( + BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() + ) + ) + ) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt new file mode 100644 index 0000000..918cd5d --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt @@ -0,0 +1,30 @@ +package com.solana.serializers + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal fun DeserializationStrategy.asSerializer() = object : KSerializer { + override val descriptor = this@asSerializer.descriptor + override fun deserialize(decoder: Decoder): T = decoder.decodeSerializableValue(this@asSerializer) + override fun serialize(encoder: Encoder, value: T) = throw NotImplementedError("Serialize not implemented") +} + +internal fun KSerializer.deserializer() = object : DeserializationStrategy { + override val descriptor = this@deserializer.descriptor + override fun deserialize(decoder: Decoder): T = decoder.decodeSerializableValue(this@deserializer) +} + +internal infix fun SerializationStrategy.with(deserializationStrategy: DeserializationStrategy) = + object : KSerializer { + override val descriptor: SerialDescriptor = this@with.descriptor + + override fun serialize(encoder: Encoder, value: T) = + encoder.encodeSerializableValue(this@with, value) + + override fun deserialize(decoder: Decoder): T = + decoder.decodeSerializableValue(deserializationStrategy) + } \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt new file mode 100644 index 0000000..25ceb82 --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt @@ -0,0 +1,21 @@ +package exchange.dydx.carteraexample.solana.web3 + +import com.solana.networking.HttpNetworkDriver +import com.solana.networking.HttpRequest +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpMethod + +class KtorNetworkDriver(val httpClient: HttpClient = HttpClient()): HttpNetworkDriver { + override suspend fun makeHttpRequest(request: HttpRequest): String = + httpClient.request(request.url) { + method = HttpMethod.parse(request.method) + request.properties.forEach { (k, v) -> + header(k, v) + } + setBody(request.body) + }.bodyAsText() +} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt new file mode 100644 index 0000000..c3447c2 --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt @@ -0,0 +1,92 @@ +package com.solana.serializers + +import com.funkatronics.kborsh.Borsh +import com.solana.rpc.Encoding +import com.solana.rpc.SolanaResponse +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +class SolanaResponseDeserializer(dataSerializer: DeserializationStrategy) + : DeserializationStrategy { + private val serializer = SolanaResponse.serializer(dataSerializer.asSerializer()) + override val descriptor: SerialDescriptor = serializer.descriptor + + override fun deserialize(decoder: Decoder): R? = + decoder.decodeSerializableValue(serializer).value +} + +internal class ByteArrayAsEncodedDataArraySerializer(val encoding: Encoding) : SerializationStrategy { + private val delegateSerializer = ListSerializer(String.serializer()) + override val descriptor: SerialDescriptor = delegateSerializer.descriptor + + override fun serialize(encoder: Encoder, value: ByteArray) = + encoder.encodeSerializableValue( + delegateSerializer, listOf(encoding.encode(value), encoding.serialName()) + ) +} + +internal object ByteArrayAsEncodedDataArrayDeserializer: DeserializationStrategy { + private val delegateSerializer = ListSerializer(String.serializer()) + override val descriptor: SerialDescriptor = delegateSerializer.descriptor + + override fun deserialize(decoder: Decoder): ByteArray { + decoder.decodeSerializableValue(delegateSerializer).apply { + Encoding.entries.forEach { enc -> + if (contains(enc.serialName())) + return enc.decode(first { it != enc.serialName() }) + } + throw(SerializationException("Unknown encoding: ${this.toTypedArray().contentToString()}")) + } + } +} + +internal class BorshAsAsEncodedDataArraySerializationStrategy( + private val dataSerializer: SerializationStrategy, + encoding: Encoding, + private val borsh: Borsh = Borsh +) : SerializationStrategy { + private val delegateSerializer = ByteArrayAsEncodedDataArraySerializer(encoding) + + override val descriptor: SerialDescriptor = dataSerializer.descriptor + + override fun serialize(encoder: Encoder, value: T?) = + encoder.encodeSerializableValue(delegateSerializer, + value?.let { + borsh.encodeToByteArray(dataSerializer, value) + } ?: byteArrayOf() + ) +} + +internal class BorshAsAsEncodedDataArrayDeserializer(private val dataSerializer: DeserializationStrategy, + private val borsh: Borsh = Borsh): DeserializationStrategy { + private val delegateDeserializer = ByteArrayAsEncodedDataArrayDeserializer + + override val descriptor: SerialDescriptor = dataSerializer.descriptor + + override fun deserialize(decoder: Decoder): T? = + decoder.decodeSerializableValue(delegateDeserializer).run { + if (this.isEmpty()) return null + borsh.decodeFromByteArray(dataSerializer, this) + } +} + +internal class BorshAsBase64JsonArraySerializer(dataSerializer: KSerializer): KSerializer { + private val borsh = Borsh + private val delegateSerializer = BorshAsAsEncodedDataArraySerializationStrategy(dataSerializer, Encoding.BASE64, borsh) + private val delegateDeserializer = BorshAsAsEncodedDataArrayDeserializer(dataSerializer, borsh) + + override val descriptor: SerialDescriptor = dataSerializer.descriptor + + override fun serialize(encoder: Encoder, value: T?) = + encoder.encodeSerializableValue(delegateSerializer, value) + + override fun deserialize(decoder: Decoder): T? = + decoder.decodeSerializableValue(delegateDeserializer) +} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt new file mode 100644 index 0000000..4db9d2a --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt @@ -0,0 +1,216 @@ +package com.solana.rpc + +import com.solana.networking.HttpNetworkDriver +import com.solana.networking.Rpc20Driver +import com.solana.publickey.SolanaPublicKey +import com.solana.rpccore.RpcRequest +import com.solana.serializers.SolanaResponseDeserializer +import com.solana.transaction.Transaction +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.serializer +import kotlin.math.pow + +class SolanaRpcClient( + private val rpcDriver: Rpc20Driver, + private val defaultTransactionOptions: TransactionOptions = TransactionOptions() +) { + + constructor( + url: String, networkDriver: HttpNetworkDriver, + defaultTransactionOptions: TransactionOptions = TransactionOptions() + ) : this(Rpc20Driver(url, networkDriver), defaultTransactionOptions) + + suspend fun requestAirdrop(address: SolanaPublicKey, amountSol: Float, requestId: String? = null) = + makeRequest( + AirdropRequest(address, (amountSol * 10f.pow(9)).toLong(), requestId), + String.serializer() + ) + + suspend fun getAccountInfo( + publicKey: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null + ) = makeRequest( + AccountInfoRequest(publicKey, commitment, minContextSlot, dataSlice, requestId), + SolanaAccountDeserializer() + ) + + suspend fun getBalance( + address: SolanaPublicKey, + commitment: Commitment = Commitment.CONFIRMED, + requestId: String? = null + ) = makeRequest( + BalanceRequest(address, commitment, requestId), + SolanaResponseDeserializer(Long.serializer()) + ) + + suspend fun getLatestBlockhash( + commitment: Commitment? = null, + minContextSlot: Long? = null, + requestId: String? = null + ) = makeRequest( + LatestBlockhashRequest(commitment, minContextSlot, requestId), + SolanaResponseDeserializer(BlockhashResponse.serializer()) + ) + + suspend fun getMinBalanceForRentExemption( + size: Long, + commitment: Commitment? = null, + requestId: String? = null + ) = makeRequest(RentExemptBalanceRequest(size, commitment, requestId), Long.serializer()) + + suspend fun getMultipleAccounts( + publicKeys: List, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null + ) = makeRequest( + MultipleAccountsInfoRequest(publicKeys, commitment, minContextSlot, dataSlice, requestId), + MultipleAccountsDeserializer() + ) + + suspend fun getProgramAccounts( + programId: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + filters: List? = null, + requestId: String? = null + ) = makeRequest( + ProgramAccountsRequest(programId, commitment, minContextSlot, dataSlice, filters, requestId), + ProgramAccountsDeserializer() + ) + + suspend fun getSignatureStatuses( + signatures: List, + searchTransactionHistory: Boolean = false, + requestId: String? = null + ) = makeRequest( + SignatureStatusesRequest(signatures, searchTransactionHistory, requestId), + SolanaResponseDeserializer(ListSerializer(SignatureStatus.serializer().nullable)) + ) + + suspend fun sendTransaction( + transaction: Transaction, + options: TransactionOptions = defaultTransactionOptions, + requestId: String? = null + ) = makeRequest(SendTransactionRequest(transaction, options, requestId), String.serializer()) + + suspend fun sendAndConfirmTransaction( + transaction: Transaction, + options: TransactionOptions = defaultTransactionOptions + ) = sendTransaction(transaction, options).apply { + result?.let { confirmTransaction(it, options) } + } + + suspend fun confirmTransaction( + transactionSignature: String, + options: TransactionOptions = defaultTransactionOptions + ): Result = withTimeout(options.timeout) { + val requiredCommitment = options.commitment.ordinal + + suspend fun confirmationStatus() = + getSignatureStatuses(listOf(transactionSignature), false) + .result?.first() + + // wait for desired transaction status + var inc = 1L + while (true) { + val confirmationOrdinal = confirmationStatus().also { + it?.err?.let { error -> + return@withTimeout Result.failure(Error(error.toString())) + } + }?.confirmationStatus?.ordinal ?: -1 + + if (confirmationOrdinal >= requiredCommitment) { + return@withTimeout Result.success(true) + } else { + // Exponential delay before retrying. + delay(500 * inc) + } + // breakout after timeout + if (!isActive) break + inc++ + } + + return@withTimeout Result.success(isActive) + } + + internal suspend inline fun makeRequest(request: RpcRequest, serializer: DeserializationStrategy) = + rpcDriver.makeRequest(request, serializer) + + internal suspend inline fun makeRequest(request: RpcRequest) = + rpcDriver.makeRequest(request, serializer()) +} + +suspend fun SolanaRpcClient.getAccountInfo( + deserializer: DeserializationStrategy, + publicKey: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null +) = makeRequest( + AccountInfoRequest(publicKey, commitment, minContextSlot, dataSlice, requestId), + SolanaAccountDeserializer(deserializer) +) + +suspend inline fun SolanaRpcClient.getAccountInfo( + publicKey: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null +) = getAccountInfo(serializer(), publicKey, commitment, minContextSlot, dataSlice, requestId) + +suspend fun SolanaRpcClient.getMultipleAccounts( + deserializer: KSerializer, + publicKeys: List, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null +) = makeRequest( + MultipleAccountsInfoRequest(publicKeys, commitment, minContextSlot, dataSlice, requestId), + MultipleAccountsDeserializer(deserializer) +) + +suspend inline fun SolanaRpcClient.getMultipleAccounts( + publicKeys: List, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + requestId: String? = null +) = getMultipleAccounts(serializer(), publicKeys, commitment, minContextSlot, dataSlice, requestId) + +suspend fun SolanaRpcClient.getProgramAccounts( + deserializer: DeserializationStrategy, + programId: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + filters: List? = null, + requestId: String? = null +) = makeRequest( + ProgramAccountsRequest(programId, commitment, minContextSlot, dataSlice, filters, requestId), + ProgramAccountsDeserializer(deserializer) +) + +suspend inline fun SolanaRpcClient.getProgramAccounts( + programId: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: AccountRequest.DataSlice? = null, + filters: List? = null, + requestId: String? = null +) = getProgramAccounts(serializer(), programId, commitment, minContextSlot, dataSlice, filters, requestId) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt new file mode 100644 index 0000000..a8dd26e --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt @@ -0,0 +1,241 @@ +package com.solana.rpc + +import com.funkatronics.encoders.Base58 +import com.funkatronics.encoders.Base64 +import com.funkatronics.hash.Sha256 +import com.solana.publickey.SolanaPublicKey +import com.solana.rpccore.JsonRpc20Request +import com.solana.transaction.Transaction +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonArrayBuilder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlin.random.Random + +sealed class SolanaRpcRequest( + method: String, + params: (JsonArrayBuilder.() -> Unit)? = null, + configuration: (JsonObjectBuilder.() -> Unit)? = null, + id: String? = null +) : JsonRpc20Request( + method, + buildJsonArray { + params?.invoke(this) + configuration?.let { + buildJsonObject(configuration).filterValues { + it != JsonNull && + !(it is JsonObject && it.jsonObject.filterValues { it != JsonNull }.isEmpty()) && + !(it is JsonArray && it.jsonArray.isEmpty() && it.jsonArray.none { it != JsonNull }) + } + }?.let { + if (it.isNotEmpty()) add(JsonObject(it)) + } + }, + id ?: generateRequestId(method) +) { + companion object { + @JvmStatic + private fun generateRequestId(method: String) = + Base64.encodeToString(Sha256.hash( + "$method-${Random.nextInt(100000000, 999999999)}" + .encodeToByteArray() + )) + } +} + +sealed class AccountRequest( + method: String, + params: (JsonArrayBuilder.() -> Unit)? = null, + configuration: (JsonObjectBuilder.() -> Unit)? = null, + id: String? = null +) : SolanaRpcRequest( + method, + params, + configuration = { + put("encoding", Encoding.BASE64.serialName()) + configuration?.invoke(this) + }, + id +) { + @Serializable + data class DataSlice(val length: Long, val offset: Long) +} + +class AccountInfoRequest( + publicKey: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: DataSlice? = null, + requestId: String? = null +) : AccountRequest( + method = "getAccountInfo", + params = { add(publicKey.base58()) }, + configuration = { + put("commitment", commitment?.serialName()) + put("minContextSlot", minContextSlot) + put("dataSlice", buildJsonObject { + put("length", dataSlice?.length) + put("offset", dataSlice?.offset) + }) + }, + requestId +) + +class MultipleAccountsInfoRequest( + publicKeys: List, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: DataSlice? = null, + requestId: String? = null +) : AccountRequest( + method = "getMultipleAccounts", + params = { addJsonArray { + publicKeys.forEach { add(it.base58()) } + }}, + configuration = { + put("commitment", commitment?.serialName()) + put("minContextSlot", minContextSlot) + put("dataSlice", buildJsonObject { + put("length", dataSlice?.length) + put("offset", dataSlice?.offset) + }) + }, + requestId +) + +class ProgramAccountsRequest( + program: SolanaPublicKey, + commitment: Commitment? = null, + minContextSlot: Long? = null, + dataSlice: DataSlice? = null, + filters: List? = null, + requestId: String? = null +) : AccountRequest( + method = "getProgramAccounts", + params = { add(program.base58()) }, + configuration = { + put("withContext", true) + put("commitment", commitment?.serialName()) + put("minContextSlot", minContextSlot) + put("dataSlice", buildJsonObject { + put("length", dataSlice?.length) + put("offset", dataSlice?.offset) + }) + filters?.let { + put("filters", JsonArray(filters.map { it.toJsonObject() })) + } + }, + requestId +) { + init { + require((filters?.size ?: 0) < 4) { "Too many filters, maximum is 4" } + } + + class DataSize(val dataSize: Long) : Filter + class MemCompare(val offset: Long, val bytes: String) : Filter + sealed interface Filter { + fun toJsonObject() = buildJsonObject { + when (this@Filter) { + is DataSize -> put("dataSize", dataSize) + is MemCompare -> { + put("offset", offset) + put("bytes", bytes) + put("encoding", Encoding.BASE64.serialName()) + } + } + } + } +} + +class AirdropRequest( + address: SolanaPublicKey, + lamports: Long, + requestId: String? = null +) : SolanaRpcRequest( + method = "requestAirdrop", + params = { + add(address.base58()) + add(lamports) + }, + id = requestId +) + +class BalanceRequest( + address: SolanaPublicKey, + commitment: Commitment = Commitment.CONFIRMED, + requestId: String? = null +) : SolanaRpcRequest( + method = "getBalance", + params = { add(address.base58()) }, + configuration = { + put("commitment", commitment.serialName()) + }, + requestId +) + +class LatestBlockhashRequest( + commitment: Commitment? = null, + minContextSlot: Long? = null, + requestId: String? = null +) : SolanaRpcRequest( + method = "getLatestBlockhash", + params = null, + configuration = { + put("commitment", commitment?.serialName()) + put("minContextSlot", minContextSlot) + }, + requestId +) + +class SendTransactionRequest( + transaction: Transaction, + options: TransactionOptions, + requestId: String? = null +) : SolanaRpcRequest( + method = "sendTransaction", + params = { add(when (options.encoding) { + Encoding.BASE58 -> Base58.encodeToString(transaction.serialize()) + Encoding.BASE64 -> Base64.encodeToString(transaction.serialize()) + })}, + configuration = { + put("encoding", options.encoding.serialName()) + put("skipPreflight", options.skipPreflight) + put("preflightCommitment", options.preflightCommitment.toString()) + }, + requestId +) + +class SignatureStatusesRequest( + transactionIds: List, + searchTransactionHistory: Boolean = false, + requestId: String? = null +) : SolanaRpcRequest( + method = "getSignatureStatuses", + params = { addJsonArray { + transactionIds.forEach { add(it) } + }}, + configuration = { + put("searchTransactionHistory", searchTransactionHistory) + }, + requestId +) + +class RentExemptBalanceRequest( + size: Long, + commitment: Commitment? = null, + requestId: String? = null +) : SolanaRpcRequest( + method = "getMinimumBalanceForRentExemption", + params = { add(size) }, + configuration = { put("commitment", commitment?.serialName()) }, + requestId +) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt new file mode 100644 index 0000000..54d0e59 --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt @@ -0,0 +1,24 @@ +package com.solana.rpc + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +class SolanaResponse(val context: Context, val value: V?) + +@Serializable +class Context(val apiVersion: String, val slot: ULong) + +@Serializable +class BlockhashResponse( + val blockhash: String, + val lastValidBlockHeight: Long +) + +@Serializable +data class SignatureStatus( + val slot: Long, + val confirmations: Long?, + var err: JsonObject?, + var confirmationStatus: Commitment? +) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt new file mode 100644 index 0000000..834ceba --- /dev/null +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt @@ -0,0 +1,49 @@ +package com.solana.rpc + +import com.funkatronics.encoders.Base58 +import com.funkatronics.encoders.Base64 +import kotlinx.serialization.SerialName +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +enum class Encoding(private val enc: String) { + @SerialName("base64") + BASE64("base64"), + @SerialName("base58") + BASE58("base58"); + + fun decode(encoded: String) = when (this) { + BASE64 -> Base64.decode(encoded) + BASE58 -> Base58.decode(encoded) + } + + fun encode(bytes: ByteArray) = when (this) { + BASE64 -> Base64.encodeToString(bytes) + BASE58 -> Base58.encodeToString(bytes) + } + + fun serialName() = toString() + override fun toString() = enc +} + +enum class Commitment(private val value: String) { + @SerialName("processed") + PROCESSED("processed"), + + @SerialName("confirmed") + CONFIRMED("confirmed"), + + @SerialName("finalized") + FINALIZED("finalized"); + + fun serialName() = toString() + override fun toString() = value +} + +data class TransactionOptions( + val commitment: Commitment = Commitment.FINALIZED, + val encoding: Encoding = Encoding.BASE64, + val skipPreflight: Boolean = false, + val preflightCommitment: Commitment = commitment, + val timeout: Duration = 30.seconds +) \ No newline at end of file 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 a3f410e..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" 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 408f412..fb378e7 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/WalletProviderProtocols.kt @@ -24,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, diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt index feaac17..0188a74 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -15,6 +15,7 @@ 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 @@ -34,8 +35,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import kotlin.random.Random class PhantomWalletProvider( @@ -166,11 +165,67 @@ class PhantomWalletProvider( } 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 + } + } + } + } } } @@ -198,27 +253,24 @@ class PhantomWalletProvider( return } - val url = "$baseUrlString/${CallbackAction.onConnect.request}" val cluster = request.chainId if (cluster == null) { completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to get chainId")) return } - val appUrl = phantomWalletConfig.appUrl.urlEncoded() - val redirectLink = "${phantomWalletConfig.callbackUrl}/${CallbackAction.onConnect}".urlEncoded() - val urlQueryParams = mapOf( - "dapp_encryption_public_key" to publickKeyEncoded, - "cluster" to cluster, - "app_url" to appUrl, - "redirect_link" to redirectLink, - ) - .map { "${it.key}=${it.value}" }.joinToString("&") - try { - val requestUrl = "$url?$urlQueryParams" - if (openPeerDeeplink(requestUrl.toUri())) { + 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, @@ -278,7 +330,7 @@ class PhantomWalletProvider( if (openPeerDeeplink(uri)) { operationCompletion = completion } else { - completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app")) + completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to open Phantom app")) } } else { completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Failed to create request URI")) @@ -292,7 +344,18 @@ class PhantomWalletProvider( status: WalletOperationStatus?, completion: WalletOperationCompletion ) { - TODO("Not yet implemented") + 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( @@ -301,7 +364,43 @@ class PhantomWalletProvider( status: WalletOperationStatus?, completion: WalletOperationCompletion ) { - TODO("Not yet implemented") + 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( @@ -377,9 +476,6 @@ class PhantomWalletProvider( } } -fun String.urlEncoded(): String { - return URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) -} data class ConnectResponse( @SerializedName("public_key") val publicKey: String?, 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 From 0f822a910abced5aedd82aa3db8177c94bf7e6f1 Mon Sep 17 00:00:00 2001 From: Rui Date: Thu, 27 Mar 2025 16:24:22 -0700 Subject: [PATCH 4/5] Fix --- README.md | 2 +- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 2 - .../carteraExample/WalletListViewModel.kt | 35 +-- .../carteraExample/solana/SolanaInteractor.kt | 211 ++++++++++++++- .../carteraExample/solana/SystemProgram.kt | 36 +++ .../carteraExample/solana/web3/AccountInfo.kt | 77 ------ .../carteraExample/solana/web3/Extensions.kt | 30 --- .../solana/web3/KtorNetworkDriver.kt | 21 -- .../solana/web3/SolanaResponseSerializers.kt | 92 ------- .../solana/web3/SolanaRpcClient.kt | 216 ---------------- .../solana/web3/SolanaRpcRequest.kt | 241 ------------------ .../solana/web3/SolanaRpcResponse.kt | 24 -- .../solana/web3/TransactionOptions.kt | 49 ---- .../exchange/dydx/cartera/CarteraProvider.kt | 4 +- .../dydx/cartera/entities/ModelExtensions.kt | 2 +- .../providers/PhantomWalletProvider.kt | 6 +- gradle.properties | 2 +- 18 files changed, 265 insertions(+), 788 deletions(-) create mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt delete mode 100644 app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt 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 2891c9e..f62b94d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,4 +84,7 @@ dependencies { 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 6769007..7e070d7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,6 @@ android:exported="true"> - @@ -38,7 +37,6 @@ android:scheme="https" android:host="v4-web-internal.vercel.app" android:pathPrefix="/phantomCarteraExample" /> - diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 847124e..39f3edd 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt @@ -47,17 +47,9 @@ class WalletListViewModel( wallets = CarteraConfig.shared?.wallets ?: listOf(), walletAction = { action: WalletList.WalletAction, wallet: Wallet?, useTestnet: Boolean, useModal: Boolean -> val chainId: String = if (useTestnet) { - if (wallet?.id == "phantom-wallet") { - "devnet" - } else { - CarteraConstants.testnetChainId - } + CarteraConstants.testnetChainId } else { - if (wallet?.id == "phantom-wallet") { - "mainnet-beta" - } else { - "1" - } + "1" } when (action) { WalletList.WalletAction.Connect -> { @@ -166,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( @@ -202,18 +199,24 @@ class WalletListViewModel( val publicKey = info?.address if (wallet?.id == "phantom-wallet" && publicKey != null) { val interactor = if (useTestnet) { - SolanaInteractor(SolanaInteractor.devnetClient) + SolanaInteractor(SolanaInteractor.devnetUrl) } else { - SolanaInteractor(SolanaInteractor.mainnetClient) + SolanaInteractor(SolanaInteractor.mainnetUrl) } val scope = CoroutineScope(Dispatchers.Unconfined) scope.launch { val response = interactor.getRecentBlockhash() - if (response.result != null) { + 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(response.result!!.blockhash) + .setRecentBlockhash(blockhash) .build() val unsignedTx = Transaction(memoTxMessage) val request = diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt index 37dee9b..327c794 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt @@ -1,33 +1,216 @@ package exchange.dydx.carteraexample.solana +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.solana.publickey.SolanaPublicKey -import com.solana.rpc.BlockhashResponse -import com.solana.rpc.SolanaRpcClient -import com.solana.rpccore.Rpc20Response import com.solana.transaction.AccountMeta import com.solana.transaction.TransactionInstruction -import exchange.dydx.carteraexample.solana.web3.KtorNetworkDriver +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 client: SolanaRpcClient + private val rpcUrl: String, ) { companion object { - val mainnetClient = SolanaRpcClient( - "https://api.mainnet-beta.solana.com", - KtorNetworkDriver() + 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 devnetClient = SolanaRpcClient("https://api.devnet.solana.com", KtorNetworkDriver()) + + 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 } - private val programId = SolanaPublicKey.from("11111111111111111111111111111111") + 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() - suspend fun getRecentBlockhash(): Rpc20Response { - return client.getLatestBlockhash() + 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 = programId, + programId = SystemProgram.programId, accounts = listOf(AccountMeta(publicKey = address, isSigner = true, isWritable = true)), data = memo.encodeToByteArray() ) -} \ No newline at end of file +} + + +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 +) \ No newline at end of file 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..4deea5c --- /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(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt deleted file mode 100644 index 5f5c5ce..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/AccountInfo.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.solana.rpc - -import com.solana.publickey.SolanaPublicKey -import com.solana.serializers.* -import com.solana.serializers.BorshAsAsEncodedDataArrayDeserializer -import com.solana.serializers.ByteArrayAsEncodedDataArrayDeserializer -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.nullable - -@Serializable -data class AccountInfo( - val data: D?, - val executable: Boolean, - val lamports: ULong, - val owner: SolanaPublicKey, - val rentEpoch: ULong, - val size: ULong? = null -) - -@Serializable -data class AccountInfoWithPublicKey

( - val account: AccountInfo

, - @SerialName("pubkey") val publicKey: String -) - -fun SolanaAccountDeserializer() = - SolanaResponseDeserializer( - AccountInfo.serializer( - ByteArrayAsEncodedDataArrayDeserializer.asSerializer() - ) - ) - -fun SolanaAccountDeserializer(deserializer: DeserializationStrategy) = - SolanaResponseDeserializer( - AccountInfo.serializer( - BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() - ) - ) - -fun MultipleAccountsDeserializer() = - SolanaResponseDeserializer( - ListSerializer( - AccountInfo.serializer( - ByteArrayAsEncodedDataArrayDeserializer.asSerializer() - ).nullable - ) - ) - -fun MultipleAccountsDeserializer(deserializer: DeserializationStrategy) = - SolanaResponseDeserializer( - ListSerializer( - AccountInfo.serializer( - BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() - ).nullable - ) - ) - -fun ProgramAccountsDeserializer() = - SolanaResponseDeserializer( - ListSerializer( - AccountInfoWithPublicKey.serializer( - ByteArrayAsEncodedDataArrayDeserializer.asSerializer() - ) - ) - ) - -fun ProgramAccountsDeserializer(deserializer: DeserializationStrategy) = - SolanaResponseDeserializer( - ListSerializer( - AccountInfoWithPublicKey.serializer( - BorshAsAsEncodedDataArrayDeserializer(deserializer).asSerializer() - ) - ) - ) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt deleted file mode 100644 index 918cd5d..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/Extensions.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.solana.serializers - -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -internal fun DeserializationStrategy.asSerializer() = object : KSerializer { - override val descriptor = this@asSerializer.descriptor - override fun deserialize(decoder: Decoder): T = decoder.decodeSerializableValue(this@asSerializer) - override fun serialize(encoder: Encoder, value: T) = throw NotImplementedError("Serialize not implemented") -} - -internal fun KSerializer.deserializer() = object : DeserializationStrategy { - override val descriptor = this@deserializer.descriptor - override fun deserialize(decoder: Decoder): T = decoder.decodeSerializableValue(this@deserializer) -} - -internal infix fun SerializationStrategy.with(deserializationStrategy: DeserializationStrategy) = - object : KSerializer { - override val descriptor: SerialDescriptor = this@with.descriptor - - override fun serialize(encoder: Encoder, value: T) = - encoder.encodeSerializableValue(this@with, value) - - override fun deserialize(decoder: Decoder): T = - decoder.decodeSerializableValue(deserializationStrategy) - } \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt deleted file mode 100644 index 25ceb82..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/KtorNetworkDriver.kt +++ /dev/null @@ -1,21 +0,0 @@ -package exchange.dydx.carteraexample.solana.web3 - -import com.solana.networking.HttpNetworkDriver -import com.solana.networking.HttpRequest -import io.ktor.client.HttpClient -import io.ktor.client.request.header -import io.ktor.client.request.request -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpMethod - -class KtorNetworkDriver(val httpClient: HttpClient = HttpClient()): HttpNetworkDriver { - override suspend fun makeHttpRequest(request: HttpRequest): String = - httpClient.request(request.url) { - method = HttpMethod.parse(request.method) - request.properties.forEach { (k, v) -> - header(k, v) - } - setBody(request.body) - }.bodyAsText() -} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt deleted file mode 100644 index c3447c2..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaResponseSerializers.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.solana.serializers - -import com.funkatronics.kborsh.Borsh -import com.solana.rpc.Encoding -import com.solana.rpc.SolanaResponse -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -class SolanaResponseDeserializer(dataSerializer: DeserializationStrategy) - : DeserializationStrategy { - private val serializer = SolanaResponse.serializer(dataSerializer.asSerializer()) - override val descriptor: SerialDescriptor = serializer.descriptor - - override fun deserialize(decoder: Decoder): R? = - decoder.decodeSerializableValue(serializer).value -} - -internal class ByteArrayAsEncodedDataArraySerializer(val encoding: Encoding) : SerializationStrategy { - private val delegateSerializer = ListSerializer(String.serializer()) - override val descriptor: SerialDescriptor = delegateSerializer.descriptor - - override fun serialize(encoder: Encoder, value: ByteArray) = - encoder.encodeSerializableValue( - delegateSerializer, listOf(encoding.encode(value), encoding.serialName()) - ) -} - -internal object ByteArrayAsEncodedDataArrayDeserializer: DeserializationStrategy { - private val delegateSerializer = ListSerializer(String.serializer()) - override val descriptor: SerialDescriptor = delegateSerializer.descriptor - - override fun deserialize(decoder: Decoder): ByteArray { - decoder.decodeSerializableValue(delegateSerializer).apply { - Encoding.entries.forEach { enc -> - if (contains(enc.serialName())) - return enc.decode(first { it != enc.serialName() }) - } - throw(SerializationException("Unknown encoding: ${this.toTypedArray().contentToString()}")) - } - } -} - -internal class BorshAsAsEncodedDataArraySerializationStrategy( - private val dataSerializer: SerializationStrategy, - encoding: Encoding, - private val borsh: Borsh = Borsh -) : SerializationStrategy { - private val delegateSerializer = ByteArrayAsEncodedDataArraySerializer(encoding) - - override val descriptor: SerialDescriptor = dataSerializer.descriptor - - override fun serialize(encoder: Encoder, value: T?) = - encoder.encodeSerializableValue(delegateSerializer, - value?.let { - borsh.encodeToByteArray(dataSerializer, value) - } ?: byteArrayOf() - ) -} - -internal class BorshAsAsEncodedDataArrayDeserializer(private val dataSerializer: DeserializationStrategy, - private val borsh: Borsh = Borsh): DeserializationStrategy { - private val delegateDeserializer = ByteArrayAsEncodedDataArrayDeserializer - - override val descriptor: SerialDescriptor = dataSerializer.descriptor - - override fun deserialize(decoder: Decoder): T? = - decoder.decodeSerializableValue(delegateDeserializer).run { - if (this.isEmpty()) return null - borsh.decodeFromByteArray(dataSerializer, this) - } -} - -internal class BorshAsBase64JsonArraySerializer(dataSerializer: KSerializer): KSerializer { - private val borsh = Borsh - private val delegateSerializer = BorshAsAsEncodedDataArraySerializationStrategy(dataSerializer, Encoding.BASE64, borsh) - private val delegateDeserializer = BorshAsAsEncodedDataArrayDeserializer(dataSerializer, borsh) - - override val descriptor: SerialDescriptor = dataSerializer.descriptor - - override fun serialize(encoder: Encoder, value: T?) = - encoder.encodeSerializableValue(delegateSerializer, value) - - override fun deserialize(decoder: Decoder): T? = - decoder.decodeSerializableValue(delegateDeserializer) -} \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt deleted file mode 100644 index 4db9d2a..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcClient.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.solana.rpc - -import com.solana.networking.HttpNetworkDriver -import com.solana.networking.Rpc20Driver -import com.solana.publickey.SolanaPublicKey -import com.solana.rpccore.RpcRequest -import com.solana.serializers.SolanaResponseDeserializer -import com.solana.transaction.Transaction -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.nullable -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.serializer -import kotlin.math.pow - -class SolanaRpcClient( - private val rpcDriver: Rpc20Driver, - private val defaultTransactionOptions: TransactionOptions = TransactionOptions() -) { - - constructor( - url: String, networkDriver: HttpNetworkDriver, - defaultTransactionOptions: TransactionOptions = TransactionOptions() - ) : this(Rpc20Driver(url, networkDriver), defaultTransactionOptions) - - suspend fun requestAirdrop(address: SolanaPublicKey, amountSol: Float, requestId: String? = null) = - makeRequest( - AirdropRequest(address, (amountSol * 10f.pow(9)).toLong(), requestId), - String.serializer() - ) - - suspend fun getAccountInfo( - publicKey: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null - ) = makeRequest( - AccountInfoRequest(publicKey, commitment, minContextSlot, dataSlice, requestId), - SolanaAccountDeserializer() - ) - - suspend fun getBalance( - address: SolanaPublicKey, - commitment: Commitment = Commitment.CONFIRMED, - requestId: String? = null - ) = makeRequest( - BalanceRequest(address, commitment, requestId), - SolanaResponseDeserializer(Long.serializer()) - ) - - suspend fun getLatestBlockhash( - commitment: Commitment? = null, - minContextSlot: Long? = null, - requestId: String? = null - ) = makeRequest( - LatestBlockhashRequest(commitment, minContextSlot, requestId), - SolanaResponseDeserializer(BlockhashResponse.serializer()) - ) - - suspend fun getMinBalanceForRentExemption( - size: Long, - commitment: Commitment? = null, - requestId: String? = null - ) = makeRequest(RentExemptBalanceRequest(size, commitment, requestId), Long.serializer()) - - suspend fun getMultipleAccounts( - publicKeys: List, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null - ) = makeRequest( - MultipleAccountsInfoRequest(publicKeys, commitment, minContextSlot, dataSlice, requestId), - MultipleAccountsDeserializer() - ) - - suspend fun getProgramAccounts( - programId: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - filters: List? = null, - requestId: String? = null - ) = makeRequest( - ProgramAccountsRequest(programId, commitment, minContextSlot, dataSlice, filters, requestId), - ProgramAccountsDeserializer() - ) - - suspend fun getSignatureStatuses( - signatures: List, - searchTransactionHistory: Boolean = false, - requestId: String? = null - ) = makeRequest( - SignatureStatusesRequest(signatures, searchTransactionHistory, requestId), - SolanaResponseDeserializer(ListSerializer(SignatureStatus.serializer().nullable)) - ) - - suspend fun sendTransaction( - transaction: Transaction, - options: TransactionOptions = defaultTransactionOptions, - requestId: String? = null - ) = makeRequest(SendTransactionRequest(transaction, options, requestId), String.serializer()) - - suspend fun sendAndConfirmTransaction( - transaction: Transaction, - options: TransactionOptions = defaultTransactionOptions - ) = sendTransaction(transaction, options).apply { - result?.let { confirmTransaction(it, options) } - } - - suspend fun confirmTransaction( - transactionSignature: String, - options: TransactionOptions = defaultTransactionOptions - ): Result = withTimeout(options.timeout) { - val requiredCommitment = options.commitment.ordinal - - suspend fun confirmationStatus() = - getSignatureStatuses(listOf(transactionSignature), false) - .result?.first() - - // wait for desired transaction status - var inc = 1L - while (true) { - val confirmationOrdinal = confirmationStatus().also { - it?.err?.let { error -> - return@withTimeout Result.failure(Error(error.toString())) - } - }?.confirmationStatus?.ordinal ?: -1 - - if (confirmationOrdinal >= requiredCommitment) { - return@withTimeout Result.success(true) - } else { - // Exponential delay before retrying. - delay(500 * inc) - } - // breakout after timeout - if (!isActive) break - inc++ - } - - return@withTimeout Result.success(isActive) - } - - internal suspend inline fun makeRequest(request: RpcRequest, serializer: DeserializationStrategy) = - rpcDriver.makeRequest(request, serializer) - - internal suspend inline fun makeRequest(request: RpcRequest) = - rpcDriver.makeRequest(request, serializer()) -} - -suspend fun SolanaRpcClient.getAccountInfo( - deserializer: DeserializationStrategy, - publicKey: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null -) = makeRequest( - AccountInfoRequest(publicKey, commitment, minContextSlot, dataSlice, requestId), - SolanaAccountDeserializer(deserializer) -) - -suspend inline fun SolanaRpcClient.getAccountInfo( - publicKey: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null -) = getAccountInfo(serializer(), publicKey, commitment, minContextSlot, dataSlice, requestId) - -suspend fun SolanaRpcClient.getMultipleAccounts( - deserializer: KSerializer, - publicKeys: List, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null -) = makeRequest( - MultipleAccountsInfoRequest(publicKeys, commitment, minContextSlot, dataSlice, requestId), - MultipleAccountsDeserializer(deserializer) -) - -suspend inline fun SolanaRpcClient.getMultipleAccounts( - publicKeys: List, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - requestId: String? = null -) = getMultipleAccounts(serializer(), publicKeys, commitment, minContextSlot, dataSlice, requestId) - -suspend fun SolanaRpcClient.getProgramAccounts( - deserializer: DeserializationStrategy, - programId: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - filters: List? = null, - requestId: String? = null -) = makeRequest( - ProgramAccountsRequest(programId, commitment, minContextSlot, dataSlice, filters, requestId), - ProgramAccountsDeserializer(deserializer) -) - -suspend inline fun SolanaRpcClient.getProgramAccounts( - programId: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: AccountRequest.DataSlice? = null, - filters: List? = null, - requestId: String? = null -) = getProgramAccounts(serializer(), programId, commitment, minContextSlot, dataSlice, filters, requestId) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt deleted file mode 100644 index a8dd26e..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcRequest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.solana.rpc - -import com.funkatronics.encoders.Base58 -import com.funkatronics.encoders.Base64 -import com.funkatronics.hash.Sha256 -import com.solana.publickey.SolanaPublicKey -import com.solana.rpccore.JsonRpc20Request -import com.solana.transaction.Transaction -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonArrayBuilder -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonObjectBuilder -import kotlinx.serialization.json.add -import kotlinx.serialization.json.addJsonArray -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.put -import kotlin.random.Random - -sealed class SolanaRpcRequest( - method: String, - params: (JsonArrayBuilder.() -> Unit)? = null, - configuration: (JsonObjectBuilder.() -> Unit)? = null, - id: String? = null -) : JsonRpc20Request( - method, - buildJsonArray { - params?.invoke(this) - configuration?.let { - buildJsonObject(configuration).filterValues { - it != JsonNull && - !(it is JsonObject && it.jsonObject.filterValues { it != JsonNull }.isEmpty()) && - !(it is JsonArray && it.jsonArray.isEmpty() && it.jsonArray.none { it != JsonNull }) - } - }?.let { - if (it.isNotEmpty()) add(JsonObject(it)) - } - }, - id ?: generateRequestId(method) -) { - companion object { - @JvmStatic - private fun generateRequestId(method: String) = - Base64.encodeToString(Sha256.hash( - "$method-${Random.nextInt(100000000, 999999999)}" - .encodeToByteArray() - )) - } -} - -sealed class AccountRequest( - method: String, - params: (JsonArrayBuilder.() -> Unit)? = null, - configuration: (JsonObjectBuilder.() -> Unit)? = null, - id: String? = null -) : SolanaRpcRequest( - method, - params, - configuration = { - put("encoding", Encoding.BASE64.serialName()) - configuration?.invoke(this) - }, - id -) { - @Serializable - data class DataSlice(val length: Long, val offset: Long) -} - -class AccountInfoRequest( - publicKey: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: DataSlice? = null, - requestId: String? = null -) : AccountRequest( - method = "getAccountInfo", - params = { add(publicKey.base58()) }, - configuration = { - put("commitment", commitment?.serialName()) - put("minContextSlot", minContextSlot) - put("dataSlice", buildJsonObject { - put("length", dataSlice?.length) - put("offset", dataSlice?.offset) - }) - }, - requestId -) - -class MultipleAccountsInfoRequest( - publicKeys: List, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: DataSlice? = null, - requestId: String? = null -) : AccountRequest( - method = "getMultipleAccounts", - params = { addJsonArray { - publicKeys.forEach { add(it.base58()) } - }}, - configuration = { - put("commitment", commitment?.serialName()) - put("minContextSlot", minContextSlot) - put("dataSlice", buildJsonObject { - put("length", dataSlice?.length) - put("offset", dataSlice?.offset) - }) - }, - requestId -) - -class ProgramAccountsRequest( - program: SolanaPublicKey, - commitment: Commitment? = null, - minContextSlot: Long? = null, - dataSlice: DataSlice? = null, - filters: List? = null, - requestId: String? = null -) : AccountRequest( - method = "getProgramAccounts", - params = { add(program.base58()) }, - configuration = { - put("withContext", true) - put("commitment", commitment?.serialName()) - put("minContextSlot", minContextSlot) - put("dataSlice", buildJsonObject { - put("length", dataSlice?.length) - put("offset", dataSlice?.offset) - }) - filters?.let { - put("filters", JsonArray(filters.map { it.toJsonObject() })) - } - }, - requestId -) { - init { - require((filters?.size ?: 0) < 4) { "Too many filters, maximum is 4" } - } - - class DataSize(val dataSize: Long) : Filter - class MemCompare(val offset: Long, val bytes: String) : Filter - sealed interface Filter { - fun toJsonObject() = buildJsonObject { - when (this@Filter) { - is DataSize -> put("dataSize", dataSize) - is MemCompare -> { - put("offset", offset) - put("bytes", bytes) - put("encoding", Encoding.BASE64.serialName()) - } - } - } - } -} - -class AirdropRequest( - address: SolanaPublicKey, - lamports: Long, - requestId: String? = null -) : SolanaRpcRequest( - method = "requestAirdrop", - params = { - add(address.base58()) - add(lamports) - }, - id = requestId -) - -class BalanceRequest( - address: SolanaPublicKey, - commitment: Commitment = Commitment.CONFIRMED, - requestId: String? = null -) : SolanaRpcRequest( - method = "getBalance", - params = { add(address.base58()) }, - configuration = { - put("commitment", commitment.serialName()) - }, - requestId -) - -class LatestBlockhashRequest( - commitment: Commitment? = null, - minContextSlot: Long? = null, - requestId: String? = null -) : SolanaRpcRequest( - method = "getLatestBlockhash", - params = null, - configuration = { - put("commitment", commitment?.serialName()) - put("minContextSlot", minContextSlot) - }, - requestId -) - -class SendTransactionRequest( - transaction: Transaction, - options: TransactionOptions, - requestId: String? = null -) : SolanaRpcRequest( - method = "sendTransaction", - params = { add(when (options.encoding) { - Encoding.BASE58 -> Base58.encodeToString(transaction.serialize()) - Encoding.BASE64 -> Base64.encodeToString(transaction.serialize()) - })}, - configuration = { - put("encoding", options.encoding.serialName()) - put("skipPreflight", options.skipPreflight) - put("preflightCommitment", options.preflightCommitment.toString()) - }, - requestId -) - -class SignatureStatusesRequest( - transactionIds: List, - searchTransactionHistory: Boolean = false, - requestId: String? = null -) : SolanaRpcRequest( - method = "getSignatureStatuses", - params = { addJsonArray { - transactionIds.forEach { add(it) } - }}, - configuration = { - put("searchTransactionHistory", searchTransactionHistory) - }, - requestId -) - -class RentExemptBalanceRequest( - size: Long, - commitment: Commitment? = null, - requestId: String? = null -) : SolanaRpcRequest( - method = "getMinimumBalanceForRentExemption", - params = { add(size) }, - configuration = { put("commitment", commitment?.serialName()) }, - requestId -) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt deleted file mode 100644 index 54d0e59..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/SolanaRpcResponse.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.solana.rpc - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -@Serializable -class SolanaResponse(val context: Context, val value: V?) - -@Serializable -class Context(val apiVersion: String, val slot: ULong) - -@Serializable -class BlockhashResponse( - val blockhash: String, - val lastValidBlockHeight: Long -) - -@Serializable -data class SignatureStatus( - val slot: Long, - val confirmations: Long?, - var err: JsonObject?, - var confirmationStatus: Commitment? -) \ No newline at end of file diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt deleted file mode 100644 index 834ceba..0000000 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/web3/TransactionOptions.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.solana.rpc - -import com.funkatronics.encoders.Base58 -import com.funkatronics.encoders.Base64 -import kotlinx.serialization.SerialName -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -enum class Encoding(private val enc: String) { - @SerialName("base64") - BASE64("base64"), - @SerialName("base58") - BASE58("base58"); - - fun decode(encoded: String) = when (this) { - BASE64 -> Base64.decode(encoded) - BASE58 -> Base58.decode(encoded) - } - - fun encode(bytes: ByteArray) = when (this) { - BASE64 -> Base64.encodeToString(bytes) - BASE58 -> Base58.encodeToString(bytes) - } - - fun serialName() = toString() - override fun toString() = enc -} - -enum class Commitment(private val value: String) { - @SerialName("processed") - PROCESSED("processed"), - - @SerialName("confirmed") - CONFIRMED("confirmed"), - - @SerialName("finalized") - FINALIZED("finalized"); - - fun serialName() = toString() - override fun toString() = value -} - -data class TransactionOptions( - val commitment: Commitment = Commitment.FINALIZED, - val encoding: Encoding = Encoding.BASE64, - val skipPreflight: Boolean = false, - val preflightCommitment: Commitment = commitment, - val timeout: Duration = 30.seconds -) \ No newline at end of file diff --git a/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt index 169ebc9..43d477e 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/CarteraProvider.kt @@ -117,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) } } @@ -141,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/providers/PhantomWalletProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt index 0188a74..8256fbb 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -253,7 +253,11 @@ class PhantomWalletProvider( return } - val cluster = request.chainId + val cluster = if (request.chainId == "1") { + "mainnet-beta" + } else { + "devnet" + } if (cluster == null) { completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to get chainId")) return diff --git a/gradle.properties b/gradle.properties index 16d029a..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=local.1742406510 +LIBRARY_VERSION_NAME=0.1.21 android.enableR8.fullMode = false \ No newline at end of file From 284fed249bbfb627f64a063dd0f4b739f1c90072 Mon Sep 17 00:00:00 2001 From: Rui Date: Thu, 27 Mar 2025 16:31:04 -0700 Subject: [PATCH 5/5] Lint --- .../dydx/carteraExample/MainActivity.kt | 1 - .../carteraExample/WalletListViewModel.kt | 8 ++-- .../carteraExample/solana/SolanaInteractor.kt | 19 +++++---- .../carteraExample/solana/SystemProgram.kt | 6 +-- .../main/java/exchange/dydx/cartera/Base58.kt | 5 +-- .../providers/PhantomWalletProvider.kt | 39 +++++++++---------- .../providers/WalletConnectModalProvider.kt | 4 +- 7 files changed, 38 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt index 56da4bf..36037f4 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/MainActivity.kt @@ -96,7 +96,6 @@ class MainActivity : ComponentActivity() { } } - @Composable fun MyApp(content: @Composable () -> Unit) { MaterialTheme { diff --git a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt index 39f3edd..35e1eb3 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/WalletListViewModel.kt @@ -198,12 +198,12 @@ class WalletListViewModel( } else { val publicKey = info?.address if (wallet?.id == "phantom-wallet" && publicKey != null) { - val interactor = if (useTestnet) { + val interactor = if (useTestnet) { SolanaInteractor(SolanaInteractor.devnetUrl) } else { SolanaInteractor(SolanaInteractor.mainnetUrl) } - val scope = CoroutineScope(Dispatchers.Unconfined) + val scope = CoroutineScope(Dispatchers.Unconfined) scope.launch { val response = interactor.getRecentBlockhash() val blockhash = response?.value?.blockhash @@ -223,7 +223,7 @@ class WalletListViewModel( WalletTransactionRequest( walletRequest = request, ethereum = null, - solana = unsignedTx.serialize() + solana = unsignedTx.serialize(), ) CoroutineScope(Dispatchers.Main).launch { doSendTransaction(request) @@ -251,7 +251,7 @@ class WalletListViewModel( WalletTransactionRequest( walletRequest = request, ethereum = ethereumRequest, - solana = null + solana = null, ) doSendTransaction(request) } diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt index 327c794..be14f0e 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SolanaInteractor.kt @@ -28,12 +28,12 @@ class SolanaInteractor( val json = mapOf( "jsonrpc" to "2.0", "id" to 1, - "method" to "getLatestBlockhash" + "method" to "getLatestBlockhash", ) val requestBody = RequestBody.create( "application/json; charset=utf-8".toMediaTypeOrNull(), - gson.toJson(json) + gson.toJson(json), ) val request = Request.Builder() @@ -59,12 +59,12 @@ class SolanaInteractor( "jsonrpc" to "2.0", "id" to 1, "method" to "getBalance", - "params" to listOf(publicKey) + "params" to listOf(publicKey), ) val requestBody = RequestBody.create( "application/json; charset=utf-8".toMediaTypeOrNull(), - gson.toJson(json) + gson.toJson(json), ) val request = Request.Builder() @@ -96,13 +96,13 @@ class SolanaInteractor( mapOf( "mint" to tokenAddress, ), - mapOf("encoding" to "jsonParsed") - ) + mapOf("encoding" to "jsonParsed"), + ), ) val requestBody = RequestBody.create( "application/json; charset=utf-8".toMediaTypeOrNull(), - gson.toJson(json) + gson.toJson(json), ) val request = Request.Builder() @@ -134,11 +134,10 @@ class SolanaInteractor( TransactionInstruction( programId = SystemProgram.programId, accounts = listOf(AccountMeta(publicKey = address, isSigner = true, isWritable = true)), - data = memo.encodeToByteArray() + data = memo.encodeToByteArray(), ) } - data class LatestBlockhashResponse( val result: LatestBlockhashResult ) @@ -213,4 +212,4 @@ data class TokenAmount( val amount: String, val decimals: Int, val uiAmount: Float -) \ No newline at end of file +) diff --git a/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt b/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt index 4deea5c..e2a16e5 100644 --- a/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt +++ b/app/src/main/java/exchange/dydx/carteraExample/solana/SystemProgram.kt @@ -7,7 +7,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder object SystemProgram { - val programId = SolanaPublicKey.from("11111111111111111111111111111111") + val programId = SolanaPublicKey.from("11111111111111111111111111111111") fun transfer( fromPublicKey: SolanaPublicKey, @@ -16,7 +16,7 @@ object SystemProgram { ): TransactionInstruction { val accounts = listOf( AccountMeta(fromPublicKey, isSigner = true, isWritable = true), - AccountMeta(toPublicKey, isSigner = false, isWritable = true) + AccountMeta(toPublicKey, isSigner = false, isWritable = true), ) // SystemProgram Instruction Layout: @@ -33,4 +33,4 @@ object SystemProgram { data = buffer.array(), ) } -} \ No newline at end of file +} diff --git a/cartera/src/main/java/exchange/dydx/cartera/Base58.kt b/cartera/src/main/java/exchange/dydx/cartera/Base58.kt index d1945a5..2e588c0 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/Base58.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/Base58.kt @@ -16,7 +16,6 @@ import org.komputing.khash.sha256.extensions.sha256 * */ - private const val ENCODED_ZERO = '1' private const val CHECKSUM_SIZE = 4 @@ -31,7 +30,6 @@ private val alphabetIndices by lazy { * @return the base58-encoded string */ fun ByteArray.encodeToBase58String(): String { - val input = copyOf(size) // since we modify it in-place if (input.isEmpty()) { return "" @@ -139,7 +137,6 @@ fun ByteArray.encodeToBase58WithChecksum() = ByteArray(size + CHECKSUM_SIZE).app 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 { @@ -159,4 +156,4 @@ fun String.decodeBase58WithChecksum(): ByteArray { } else { throw IllegalArgumentException("Checksum mismatch: $checksum is not computed checksum $computedChecksum") } -} \ No newline at end of file +} diff --git a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt index 8256fbb..b4375ba 100644 --- a/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt +++ b/cartera/src/main/java/exchange/dydx/cartera/walletprovider/providers/PhantomWalletProvider.kt @@ -40,7 +40,7 @@ import kotlin.random.Random class PhantomWalletProvider( private val phantomWalletConfig: PhantomWalletConfig, private val application: Application, -): WalletOperationProviderProtocol { +) : WalletOperationProviderProtocol { private enum class CallbackAction(val request: String) { onConnect("connect"), @@ -68,7 +68,7 @@ class PhantomWalletProvider( private var phantomPublicKey: ByteArray? = null private var session: String? = null - private var connectionCompletion: WalletConnectCompletion? = null + private var connectionCompletion: WalletConnectCompletion? = null private var connectionWallet: Wallet? = null private var operationCompletion: WalletOperationCompletion? = null @@ -96,7 +96,7 @@ class PhantomWalletProvider( val data = decryptPayload( payload = uri.getQueryParameter("data"), - nonce = uri.getQueryParameter("nonce") + nonce = uri.getQueryParameter("nonce"), ) val response = try { Gson().fromJson(data?.decodeToString(), ConnectResponse::class.java) @@ -108,7 +108,7 @@ class PhantomWalletProvider( val walletInfo = WalletInfo( address = response.publicKey, chainId = null, - wallet = connectionWallet + wallet = connectionWallet, ) _walletStatus.state = WalletState.CONNECTED_TO_WALLET _walletStatus.connectedWallet = walletInfo @@ -142,7 +142,7 @@ class PhantomWalletProvider( } else { val data = decryptPayload( payload = uri.getQueryParameter("data"), - nonce = uri.getQueryParameter("nonce") + nonce = uri.getQueryParameter("nonce"), ) val response = try { Gson().fromJson(data?.decodeToString(), SignMessageResponse::class.java) @@ -174,7 +174,7 @@ class PhantomWalletProvider( } else { val data = decryptPayload( payload = uri.getQueryParameter("data"), - nonce = uri.getQueryParameter("nonce") + nonce = uri.getQueryParameter("nonce"), ) val response = try { Gson().fromJson(data?.decodeToString(), SignTransactionResponse::class.java) @@ -206,7 +206,7 @@ class PhantomWalletProvider( } else { val data = decryptPayload( payload = uri.getQueryParameter("data"), - nonce = uri.getQueryParameter("nonce") + nonce = uri.getQueryParameter("nonce"), ) val response = try { Gson().fromJson(data?.decodeToString(), SendTransactionResponse::class.java) @@ -238,7 +238,7 @@ class PhantomWalletProvider( return } - val result = TweetNaclFast.Box.keyPair() + val result = TweetNaclFast.Box.keyPair() if (result == null) { completion(null, WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to generate key pair")) return @@ -278,7 +278,7 @@ class PhantomWalletProvider( } else { completion( null, - WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app") + WalletError(CarteraErrorCode.CONNECTION_FAILED, "Failed to open Phantom app"), ) } } catch (e: Exception) { @@ -324,11 +324,11 @@ class PhantomWalletProvider( val request = SignMessageRequest( session = session, message = message.toByteArray().encodeToBase58String(), - display = "utf8" + display = "utf8", ) val uri = createRequestUri( request = Gson().toJson(request), - action = CallbackAction.onSignMessage + action = CallbackAction.onSignMessage, ) if (uri != null) { if (openPeerDeeplink(uri)) { @@ -348,7 +348,7 @@ class PhantomWalletProvider( status: WalletOperationStatus?, completion: WalletOperationCompletion ) { - val message = typedDataProvider?.typedDataAsString + val message = typedDataProvider?.typedDataAsString if (message == null) { completion(null, WalletError(CarteraErrorCode.UNEXPECTED_RESPONSE, "Typed data is null")) return @@ -358,7 +358,7 @@ class PhantomWalletProvider( message = message, connected = connected, status = status, - completion = completion + completion = completion, ) } @@ -390,11 +390,11 @@ class PhantomWalletProvider( val sendRequest = SendTransactionRequest( session = session, - transaction = data.encodeToBase58String() + transaction = data.encodeToBase58String(), ) val uri = createRequestUri( request = Gson().toJson(sendRequest), - action = CallbackAction.onSendTransaction + action = CallbackAction.onSendTransaction, ) if (uri != null) { if (openPeerDeeplink(uri)) { @@ -466,11 +466,11 @@ class PhantomWalletProvider( .appendQueryParameter("nonce", nonce.encodeToBase58String()) .appendQueryParameter( "redirect_link", - "${phantomWalletConfig.callbackUrl}/${action.name}" + "${phantomWalletConfig.callbackUrl}/${action.name}", ) .appendQueryParameter( "dapp_encryption_public_key", - publicKey.encodeToBase58String() + publicKey.encodeToBase58String(), ) .build() return uri @@ -480,7 +480,6 @@ class PhantomWalletProvider( } } - data class ConnectResponse( @SerializedName("public_key") val publicKey: String?, @SerializedName("session") val session: String? @@ -493,7 +492,7 @@ data class DisconnectRequest( data class SignMessageRequest( @SerializedName("session") val session: String?, @SerializedName("message") val message: String?, - @SerializedName("display") val display: String? // "utf8" | "hex" + @SerializedName("display") val display: String? // "utf8" | "hex" ) data class SignMessageResponse( @@ -523,4 +522,4 @@ data class PhantomSession( @SerializedName("timestamp") val timestamp: String?, @SerializedName("chain") val chain: String?, @SerializedName("cluster") val cluster: String? -) \ No newline at end of file +) 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 a19e6d9..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