commonMain.io.eqoty.secretk.client.RestClient.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of client-jvm Show documentation
Show all versions of client-jvm Show documentation
A Kotlin multiplatform REST client utilizing secret network's gRPC gateway endpoints.
package io.eqoty.secretk.client
import com.ionspin.kotlin.bignum.integer.BigInteger
import com.ionspin.kotlin.bignum.serialization.kotlinx.biginteger.bigIntegerhumanReadableSerializerModule
import io.eqoty.secretk.BroadcastMode
import io.eqoty.secretk.types.proto.MsgExecuteContractResponseProto
import io.eqoty.secretk.types.proto.MsgInstantiateContractResponseProto
import io.eqoty.secretk.types.proto.MsgProto
import io.eqoty.secretk.types.response.*
import io.eqoty.secretk.types.response.logs.Log
import io.eqoty.secretk.utils.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
import kotlinx.serialization.modules.polymorphic
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
/**
* Creates a new client to interact with a Cosmos SDK light client daemon.
* This class tries to be a direct mapping onto the API. Some basic decoding and normalizatin is done
* but things like caching are done at a higher level.
*
* When building apps, you should not need to use this class directly. If you do, this indicates a missing feature
* in higher level components. Feel free to raise an issue in this case.
*
* @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API)
* @param broadcastMode Defines at which point of the transaction processing the postTx method (i.e. transaction broadcasting) returns
* @param seed - The seed used to generate sender TX encryption key. If empty will generate random new one
*/
internal class RestClient(
val apiUrl: String,
val broadcastMode: BroadcastMode = BroadcastMode.Block,
val enigmautils: EncryptionUtils
) {
val codeHashCache: MutableMap = mutableMapOf()
val json: Json = Json {
ignoreUnknownKeys = true
serializersModule = bigIntegerhumanReadableSerializerModule + SerializersModule {
polymorphic(TypeValue::class) {
subclass(AuthAccountsResult::class, AuthAccountsResult.serializer())
}
}
}
val client: HttpClient = HttpClient {
install(DefaultRequest) {
contentType(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(json)
}
}
suspend inline fun get(path: String): T {
val response = try {
this.client.get(this.apiUrl + path)
} catch (e: ResponseException) {
throw parseError(e)
}
return response.body()
}
suspend inline fun post(path: String, params: JsonObject): T {
val response = try {
this.client.post(this.apiUrl + path) {
setBody(params)
}
} catch (e: ResponseException) {
throw parseError(e)
}
return response.body()
}
/**
* We want to get message data from 500 errors
* https://stackoverflow.com/questions/56577124/how-to-handle-500-error-message-with-axios
* this should be chained to catch one error and throw a more informative one
*/
suspend fun parseError(err: ResponseException): Error {
// use the error message sent from server, not default 500 msg
val body = err.response.bodyAsText()
if (body != "") {
throw Error("$body (HTTP ${err.response.status})")
} else {
throw err
}
}
/**
* Broadcasts a signed transaction to into the transaction pool.
* Depending on the RestClient's broadcast mode, this might or might
* wait for checkTx or deliverTx to be executed before returning.
*
* @param tx a signed transaction as StdTx (i.e. not wrapped in type/value container)
*/
suspend inline fun postTx(tx: UByteArray, simulate: Boolean): T {
val txString = tx.toByteString().base64()
val params =
json.parseToJsonElement(
"""{
"tx_bytes": "$txString",
"mode": "${this.broadcastMode.mode}"
}"""
).jsonObject
// https://v1.cosmos.network/rpc/v0.45.1
val path = if (simulate) "/cosmos/tx/v1beta1/simulate" else "/cosmos/tx/v1beta1/txs"
return post(path, params)
}
suspend fun getCodeHashByContractAddr(addr: String): String {
val codeHashFromCache = codeHashCache[addr]
if (codeHashFromCache != null) {
return codeHashFromCache
}
// Works... for now...
// But we may need to switch to getting hash by grpc gateway endpoints:
// getting codeId from /compute/v1beta1/contract/{address}
// then using codeId to get hash with:
// /compute/v1beta1/code/{code_id} endpoint
val path = "/wasm/contract/${addr}/code-hash"
val responseData: WasmResponse = get(path)
codeHashCache[addr] = responseData.result
return responseData.result
}
suspend fun getCodeInfoByCodeId(codeId: String): CodeInfoResponse.CodeInfo {
val path = "/compute/v1beta1/code/$codeId"
val responseData: CodeInfoResponse = get(path)
return responseData.codeInfo
}
/**
* Makes a smart query on the contract and parses the reponse as JSON.
* Throws error if no such contract exists, the query format is invalid or the response is invalid.
*/
suspend fun queryContractSmart(
contractAddress: String,
query: JsonObject,
_contractCodeHash: String? = null,
): String {
val contractCodeHash = if (_contractCodeHash == null) {
this.getCodeHashByContractAddr(contractAddress)
} else {
this.codeHashCache[contractAddress] = _contractCodeHash
_contractCodeHash
}
val encrypted = this.enigmautils.encrypt(contractCodeHash, query)
val nonce = encrypted.sliceArray(IntRange(0, 31)).toUByteArray()
val encoded = encrypted.toByteArray().toByteString().base64Url()
val path = "/compute/v1beta1/query/${contractAddress}?query=${encoded}"
val response: String = try {
val responseData: SmartQueryResponse = get(path)
when (responseData.data != null) {
true -> {
val decryptedResponse =
enigmautils.decrypt(responseData.data.decodeBase64()!!.toUByteArray(), nonce)
decryptedResponse.decodeToString().decodeBase64()!!.utf8().trimEnd()
}
false -> {
throw Error(responseData.message)
}
}
} catch (err: Throwable) {
val message = err.message ?: throw err
val errorMessageRgx = Regex("""encrypted: (.+?): (?:instantiate|execute|query|reply to) contract failed""")
val matches = errorMessageRgx.findAll(message).toList()
if (matches.isEmpty() || matches.first().groupValues.size < 2) {
throw err
}
val decodedError: Error = try {
val errorCipherB64 = matches.first().groupValues[1]
val errorCipherBz = errorCipherB64.decodeBase64()!!.toUByteArray()
val errorPlainBz = enigmautils.decrypt(errorCipherBz, nonce).decodeToString()
Error(errorPlainBz)
} catch (decryptionError: Throwable) {
Error("Failed to decrypt the following error message: ${err.message}. Due to decryptionError: $decryptionError")
}
throw decodedError
}
return response
}
suspend fun decryptDataField(msg: MsgProto, nonce: UByteArray?): UByteArray {
val dataField = when (msg) {
is MsgExecuteContractResponseProto -> {
msg.data.toUByteArray()
}
is MsgInstantiateContractResponseProto -> {
msg.data?.toUByteArray() ?: UByteArray(0) { 0u }
}
else -> UByteArray(0) { 0u }
}
if (nonce == null) {
// nonce was not extracted from message. Nothing to decrypt
return dataField
}
val wasmOutputDataCipherBz = dataField
val error: Throwable
try {
val data = enigmautils
.decrypt(wasmOutputDataCipherBz, nonce)
.decodeToString()
.decodeBase64()!!
.toUByteArray()
return data
} catch (t: Throwable) {
error = t
}
throw error
}
suspend fun authAccounts(address: String): CosmosSdkAccount {
val authResp: AccountResponse? = try {
get("/cosmos/auth/v1beta1/accounts/${address}")
} catch (t: Throwable) {
t.printStackTrace()
null
}
val bankResp: BalanceResponse? = try {
get("/cosmos/bank/v1beta1/balances/${address}")
} catch (t: Throwable) {
t.printStackTrace()
null
}
return CosmosSdkAccount(
address = authResp?.account?.address,
coins = bankResp?.balances,
public_key = authResp?.account?.pub_key?.let { PubKeySecp256k1(it.key) },
account_number = authResp?.account?.account_number ?: BigInteger.ZERO,
sequence = authResp?.account?.sequence ?: BigInteger.ZERO,
)
}
// The /node_info endpoint
suspend fun nodeInfo(): NodeInfoResponse {
return get("/node_info")
}
suspend fun decryptLogs(logs: List, nonces: List): List {
for (l in logs) {
for (e in l.events) {
if (e.type == "wasm") {
for (nonce in nonces.filterNotNull()) {
var nonceOk = false
for (a in e.attributes) {
try {
a.key = this.enigmautils.decrypt(a.key.decodeBase64()!!.toUByteArray(), nonce)
.decodeToString()
nonceOk = true
} catch (_: Throwable) {
}
try {
a.value = this.enigmautils.decrypt(a.value.decodeBase64()!!.toUByteArray(), nonce)
.decodeToString()
nonceOk = true
} catch (_: Throwable) {
}
}
if (nonceOk) {
continue
}
}
}
}
}
return logs
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy