Skip to content

WalletConnect protocol V1 implementation for Kotlin and Android projects

License

Notifications You must be signed in to change notification settings

jemshit/WalletConnect

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WalletConnect V1

Implementation of WalletConnect protocol V1 in Kotlin.

  • Heavily uses Kotlin Coroutines.
  • Extendable, you can provide your own implementation of almost anything
  • Can be used in any Kotlin or Android project. Only Android sample app is provided.
  • ⚠️ Warning: Usage from Java projects is not tested
  • ⚠️ Warning: APIs are not final yet, breaking changes should be expected

Install

Release

1. DApp & Wallet

implementation("com.jemshit.walletconnect:walletconnect:x.y.z")

You can provide your own implementation of DApp and Wallet

2. Socket

implementation("com.jemshit.walletconnect:walletconnect-socket-scarlet:x.y.z")

You can provide your own implementation of Socket

3. Adapter

implementation("com.jemshit.walletconnect:walletconnect-adapter-gson:x.y.z")
// or
implementation("com.jemshit.walletconnect:walletconnect-adapter-moshi:x.y.z")

You can provide your own implementation of JsonAdapter

4. Session Store

implementation("com.jemshit.walletconnect:walletconnect-store-file:x.y.z")
// or (Android only)
implementation("com.jemshit.walletconnect:walletconnect-store-prefs:x.y.z")

You can provide your own implementation of SessionStore

5. Custom Request Models

implementation("com.jemshit.walletconnect:walletconnect-requests:x.y.z")

6. Other

7. Base module to provide your own implementations

implementation("com.jemshit.walletconnect:walletconnect-core:x.y.z")

Usage

Make sure to check Documentation of Functions & Models. They contain helpful information.

Create DApp/Wallet
fun createDApp(sessionStoreName: String)
        : DApp {
    return DAppManager(
            socket = createSocket(),
            sessionStore = createSessionStore(sessionStoreName),
            jsonAdapter = createJsonAdapter(),
            dispatcherProvider,
            logger
    )
}

fun createWallet(sessionStoreName: String)
        : Wallet {
    return WalletManager(
            socket = createSocket(),
            sessionStore = createSessionStore(sessionStoreName),
            jsonAdapter = createJsonAdapter(),
            dispatcherProvider,
            logger
    )
}

fun createSocketService(url: String,
                        lifecycleRegistry: LifecycleRegistry)
        : SocketService {

    // you can ignore this
    val interceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.HEADERS
    }

    // change depending on your needs
    val okHttpClient = OkHttpClient.Builder()
            .callTimeout(10, TimeUnit.SECONDS)
            .readTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(10, TimeUnit.SECONDS)
            // "https://bridge.walletconnect.org" -> i think BridgeServer responds with "missing or invalid socket data"
            // "https://safe-walletconnect.gnosis.io" -> ping works fine
            .pingInterval(4, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()

    val webSocketFactory = okHttpClient.newWebSocketFactory(url)

    // you can use something else instead of Gson, make sure to provide SocketMessageTypeAdapter() & JsonRpcMethodTypeAdapter()
    val gson = GsonBuilder()
            .registerTypeAdapter(SocketMessageType::class.java, SocketMessageTypeAdapter())
            .registerTypeAdapter(JsonRpcMethod::class.java, JsonRpcMethodTypeAdapter())
            .create()

    val scarlet = Scarlet.Builder()
            .webSocketFactory(webSocketFactory)
            .addMessageAdapterFactory(GsonMessageAdapter.Factory(gson))
            .addStreamAdapterFactory(FlowStreamAdapter.Factory)
            .backoffStrategy(ExponentialBackoffStrategy(initialDurationMillis = 1_000L,
                                                        maxDurationMillis = 8_000L))
            .lifecycle(lifecycleRegistry)
            .build()

    return scarlet.create(SocketService::class.java)
}

fun createSocket()
        : Socket {
    val gson = GsonBuilder()
            .registerTypeAdapter(SocketMessageType::class.java, SocketMessageTypeAdapter())
            .registerTypeAdapter(JsonRpcMethod::class.java, JsonRpcMethodTypeAdapter())
            .create()

    return SocketManager(
            socketServiceFactory = { url, lifecycleRegistry -> createSocketService(url, lifecycleRegistry) },
            gson,
            dispatcherProvider,
            logger
    )
}

fun createSessionStore(name: String)
        : SessionStore {

    // return anything that implements SessionStore

    //val sharedPrefs = requireContext().applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE)
    //return SharedPrefsSessionStore(
    //        sharedPrefs,
    //        dispatcherProvider,
    //        logger
    //)

    return FileSessionStore(
            File(requireContext().filesDir, "$name.json"),
            dispatcherProvider,
            logger
    )
}

fun createJsonAdapter()
        : JsonAdapter {

    // return anything that implements JsonAdapter. Make sure to provide SocketMessageTypeAdapter() & JsonRpcMethodTypeAdapter()

    //val gson = GsonBuilder()
    //        .registerTypeAdapter(SocketMessageType::class.java, SocketMessageTypeAdapter())
    //        .registerTypeAdapter(JsonRpcMethod::class.java, JsonRpcMethodTypeAdapter())
    //        .serializeNulls()
    //        .create()
    //
    //return GsonAdapter(gson)


    val moshi = Moshi.Builder()
            .add(walletconnect.adapter.moshi.type_adapter.SocketMessageTypeAdapter())
            .add(walletconnect.adapter.moshi.type_adapter.JsonRpcMethodTypeAdapter())
            .addLast(KotlinJsonAdapterFactory())
            .build()
    return MoshiAdapter(moshi)
}
DApp

Init

val connectionParams = ConnectionParams(
        topic = UUID.randomUUID().toString(), // unique topic = unique session
        version = "1",
        // "https://bridge.walletconnect.org" -> when one peer deletes session while other peer is disconnected, 
        //     other peer never gets that message even after connecting. Also pings in socket is not supported
        bridgeUrl = "https://safe-walletconnect.gnosis.io",
        symmetricKey = "..." // 32 byte (64 char) encryption/decryption key. You can use `Cryptography.generateSymmetricKey().toHex()`
)

val initialSessionState = InitialSessionState(
        connectionParams,
        myPeerId = UUID.randomUUID().toString(),
        myPeerMeta = PeerMeta(
                name = "DApp",
                url = "https://dapp.com",
                description = "DApp Description",
                icons = listOf("https://www.dapp.com/img/Icon_Logotype_2.png")
        )
)

val dApp: DApp = createDApp(sessionStoreName = "...")

Open Socket

dApp.openSocketAsync(initialSessionState,
                     callback = ::onSessionCallback,
                     onOpen = { freshOpened ->
                         // sendSessionRequest() etc...
                     })
// or
coroutineScope.launch(dispatcherProvider.io()) {
    val freshOpened = dApp.openSocket(initialSessionState,
                                      callback = ::onSessionCallback)
    // sendSessionRequest() etc...
}

Close

dApp.closeAsync(deleteLocal = false,
                deleteRemote = false,
                onClosed = { freshClosed ->
                    // ..
                })
// or 
coroutineScope.launch(dispatcherProvider.io()) {
    val freshClosed = dApp.close(deleteLocal = false,
                                 deleteRemote = false)
    // ...
}

// close & delete session
dApp.closeAsync(deleteLocal = true,
                deleteRemote = true,
                onClosed = { freshClosed ->
                    // ..
                })

Session Request

dApp.sendSessionRequest(chainId)

Sign Request

coroutineScope.launch(dispatcherProvider.io()) {
    val ethSign = EthSign(address = "...",
                          message = "...", // raw string for SignType.Sign, hex string for SignType.PersonalSign
                          type = SignType.Sign) // or SignType.PersonalSign

    // ethSign.validate()

    val messageId: Long? = dApp.sendRequest(
            method = ethSign.type.toMethod(),
            data = ethSign.toList(),
            itemType = String::class.java
    )
    // You can store Map<messageId, MyCallback>, so when you get response for this messageId in 'onSessionCallback', 
    //  you can invoke corresponding MyCallback
}

// or
dApp.sendRequestAsync(
        method = ethSign.type.toMethod(),
        data = ethSign.toList(),
        itemType = String::class.java,
        onRequested = {},
        onRequestError = {},
        onCallback = {}
)

EthSendTransaction Request

// Check sample for sending custom token using SmartContract address. 
// There is also gas estimation API example for Binance Smart Chain
// Check HexByteExtensions.kt for 'toHex' extension on String/Long/Int
fun createTransaction()
        : EthTransaction {
    return EthTransaction(
            from = approvedAddress,
            to = "0x621261D26847B423Df639848Fb53530025a008e8",
            data = "",
            chainId = approvedChainId.toHex(),

            gas = null,
            gasPrice = null,
            gasLimit = null,
            maxFeePerGas = null,
            maxPriorityFeePerGas = null,

            value = "0x" + BigInteger("10000000000000000").toString(16), // 1_000_000_000_000_000_000L.toHex(),
            nonce = null
    )
}

coroutineScope.launch(dispatcherProvider.io()) {
    // you can call EthTransaction.validate() before sending
    val messageId: Long? = dApp.sendRequest(
            EthRpcMethod.SendTransaction,
            data = listOf(createTransaction()),
            itemType = EthTransaction::class.java
    )
    // You can store Map<messageId, MyCallback>, so when you get response for this messageId in 'onSessionCallback', 
    //  you can invoke corresponding MyCallback
}
// or
dApp.sendRequestAsync(
        EthRpcMethod.SendTransaction,
        data = listOf(createTransaction()),
        itemType = EthTransaction::class.java,
        onRequested = {},
        onRequestError = {},
        onCallback = {}
)

Custom Request

// check JsonRpcMethod file for list of default provided
coroutineScope.launch(dispatcherProvider.io()) {
    val messageId: Long? = dApp.sendRequest(
            CustomRpcMethod("some_method_name"),
            data = listOf(MyClass()),
            itemType = MyClass::class.java
    )
}

// or
dApp.sendRequestAsync(
        CustomRpcMethod("some_method_name"),
        data = listOf(MyClass()),
        itemType = MyClass::class.java,
        onRequested = {},
        onRequestError = {},
        onCallback = {}
)

Custom SocketMessage

// 'dApp.sendRequest' uses 'encryptPayloadAndPublish' under the hood, you can use 'encryptPayloadAndPublish' directly

Other

// dApp.generateMessageId()
// dApp.getInitialSessionState()   // inherited from SessionLifecycle interface
// dApp.disconnectSocket()         // inherited from SessionLifecycle interface
// dApp.reconnectSocket()          // inherited from SessionLifecycle interface

// Callbacks
fun onSessionCallback(callbackData: CallbackData) {
    coroutineScope.launch(dispatcherProvider.ui()) {
        when (callbackData) {
            is SessionCallback -> {
                when (callbackData) {
                    // ...        
                }
            }
            is SocketCallback -> {
                when (callbackData) {
                    // ...
                }
            }
            is RequestCallback -> {
                when (callbackData) {
                    // ...
                }
            }
            is FailureCallback -> {
                // ...
            }
        }
    }
}

// DeepLink to Wallet app
fun triggerDeepLink() {
    val currentSessionState = dApp.getInitialSessionState() ?: return
    try {
        val myIntent = Intent(Intent.ACTION_VIEW, Uri.parse(currentSessionState.connectionParams.toUri()))
        startActivity(myIntent)
    } catch (_: ActivityNotFoundException) {
        Toast
                .makeText(requireContext(),
                          "No application can handle this request. Please install a wallet app",
                          Toast.LENGTH_LONG)
                .show()
    }
}
Wallet

Init

val connectionParams: ConnectionParams // get through deeplink, QR code ...

val initialSessionState = InitialSessionState(
        connectionParams,
        myPeerId = UUID.randomUUID().toString(),
        myPeerMeta = PeerMeta(
                name = "Wallet",
                url = "https://wallet.com",
                description = "Wallet Description",
                icons = listOf("https://img.favpng.com/1/20/24/wallet-icon-png-favpng-TQrAD3mHXn7Yey6wnt6aa97YF.jpg")
        )
)

val wallet: Wallet = createWallet(sessionStoreName = "...")

Open Socket

wallet.openSocketAsync(initialSessionState,
                       callback = ::onSessionCallback,
                       onOpen = { freshOpened ->
                           // sendSessionRequest() etc...
                       })
// or
coroutineScope.launch(dispatcherProvider.io()) {
    val freshOpened = wallet.openSocket(initialSessionState,
                                        callback = ::onSessionCallback)
    // sendSessionRequest() etc...
}

Close

wallet.closeAsync(deleteLocal = false,
                  deleteRemote = false,
                  onClosed = { freshClosed ->
                      // ..
                  })
// or 
coroutineScope.launch(dispatcherProvider.io()) {
    val freshClosed = wallet.close(deleteLocal = false,
                                   deleteRemote = false)
    // ...
}

// close & delete session
wallet.closeAsync(deleteLocal = true,
                  deleteRemote = true,
                  onClosed = { freshClosed ->
                      // ..
                  })

Session Request

// Approve
wallet.approveSession(chainId = 1,
                      accounts = listOf("0x621261D26847B423Df639848Fb53530025a008e8"))

// Reject
wallet.rejectSession()

// Update
// if 'approved' is false, close() is called internally. Session is deleted from both peers
wallet.updateSession(chainId = 2,
                     accounts = listOf("0x621261D26847B423Df639848Fb53530025a008e8"),
                     approved = true)

Other Requests

// respond with same messageId!

// Approve
wallet.approveRequest(messageId,
                      result = signature, // or anything else
                      resultType = String::class.java)

// Reject
wallet.rejectRequest(messageId,
                     JsonRpcErrorData())

Custom SocketMessage

// you can use 'encryptPayloadAndPublish' directly to send custom SocketMessage

Other

// dApp.generateMessageId()
// dApp.getInitialSessionState()   // inherited from SessionLifecycle interface
// dApp.disconnectSocket()         // inherited from SessionLifecycle interface
// dApp.reconnectSocket()          // inherited from SessionLifecycle interface

// Callbacks
fun onSessionCallback(callbackData: CallbackData) {
    coroutineScope.launch(dispatcherProvider.ui()) {
        when (callbackData) {
            is SessionCallback -> {
                when (callbackData) {
                    // ...        
                }
            }
            is SocketCallback -> {
                when (callbackData) {
                    // ...
                }
            }
            is RequestCallback -> {
                when (callbackData) {
                    // ...
                }
            }
            is FailureCallback -> {
                // ...
            }
        }
    }
}
Local Session List
// obtain same sessionStore used for DApp/Wallet
val sessionStore = return FileSessionStore(
        File(requireContext().filesDir, "$name.json"),
        dispatcherProvider,
        logger
)

// one-time list
coroutineScope.launch(dispatcherProvider.io()) {
    val sessions: Set? = sessionStore.getAll()
}

// list as hot flow
sessionStore.getAllAsFlow()
        .onEach {}
        .catch {}
        .launchIn(coroutineScope)

// Check SessionStore for other methods
Helpers

Cryptography.kt

  • encrypt
  • decrypt
  • computeHMAC
  • randomBytes
  • generateSymmetricKey

HexByteExtensions

  • String.hexToByteArray
  • String.isHex
  • String.toHex
  • ByteArray.toHex
  • Long.toHex
  • Int.toHex

Sample

Sample Android App

List         List Error

Image 1:

  1. PeerId & Logo of DApp/Wallet
  2. Approved ChainId & Accounts
  3. Logs (make sure to press 'Clear' button once a while)
  4. Callbacks
  5. Action Buttons (can scroll horizontally)
  6. Session List (image 2)
  7. Color changes depending on Socket connection state (orange/green/red)

Image 2:

  • Click to reconnect to any previous Session
  • Press delete icon to delete any previous Session

Other notes:

  • Single unique topic is created per app open (kill & reopen app to change topic)
  • dApp (above half of screen) & wallet (below half of screen) share same topic

Changelog & Migration

CHANGELOG.md

ProGuard (Android)

Example in sample

WalletConnect
### WalletConnect
-keepclassmembers class walletconnect.core.requests.eth.** {
    public synthetic <methods>;
    <methods>;
    <fields>;
}
-keepclassmembers class walletconnect.core.session.model.** {
    public synthetic <methods>;
    <methods>;
    <fields>;
}
-keepclassmembers class walletconnect.core.session_state.model.** {
    public synthetic <methods>;
    <methods>;
    <fields>;
}
-keepclassmembers class walletconnect.core.socket.model.** {
    public synthetic <methods>;
    <methods>;
    <fields>;
}
-keepclassmembers class walletconnect.requests.** {
    public synthetic <methods>;
    <methods>;
    <fields>;
}
Other dependencies
### Kotlin
# https://stackoverflow.com/questions/33547643/how-to-use-kotlin-with-proguard
# https://medium.com/@AthorNZ/kotlin-metadata-jackson-and-proguard-f64f51e5ed32
-keepclassmembers class **$WhenMappings {
    <fields>;
}
-keep class kotlin.Metadata { *; }
-keepclassmembers class kotlin.Metadata {
    public <methods>;
}

### Kotlin Coroutine
# Most of volatile fields are updated with AFU and should not be mangled
# ServiceLoader support
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
# Most of volatile fields are updated with AFU and should not be mangled
-keepclassmembernames class kotlinx.** {
    volatile <fields>;
}
# Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater
-keepclassmembernames class kotlin.coroutines.SafeContinuation {
    volatile <fields>;
}
-dontwarn kotlinx.atomicfu.**
-dontwarn kotlinx.coroutines.flow.**

-keep class kotlin.Metadata { *; }
-keepclassmembers class kotlin.Metadata {
    public <methods>;
}

### Gson 
# uses generic type information stored in a class file when working with fields. 
# Proguard removes such information by default, so configure it to keep all of it.
-keepattributes Signature
# For using GSON @Expose annotation
-keepattributes *Annotation*
# Gson specific classes
-dontwarn sun.misc.**
#-keep class com.google.gson.stream.** { *; }
# Application classes that will be serialized/deserialized over Gson
-keep class com.google.gson.examples.android.model.** { <fields>; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Prevent R8 from leaving Data object members always null
-keepclassmembers,allowobfuscation class * {
  @com.google.gson.annotations.SerializedName <fields>;
}

### JSR305
-dontwarn javax.annotation.**

### OkHttp3
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase

Todo