Skip to content

Commit

Permalink
transaction options and better confirmation
Browse files Browse the repository at this point in the history
  • Loading branch information
Funkatronics committed Jun 7, 2024
1 parent 7f5e203 commit 2af6dd9
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 75 deletions.
218 changes: 145 additions & 73 deletions solanaclient/src/commonMain/kotlin/com/solana/rpc/SolanaRpcClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,83 +5,140 @@ import com.solana.networking.HttpNetworkDriver
import com.solana.networking.Rpc20Driver
import com.solana.publickey.SolanaPublicKey
import com.solana.rpccore.JsonRpc20Request
import com.solana.rpccore.RpcRequest
import com.solana.serializers.SolanaResponseSerializer
import com.solana.transaction.Transaction
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.*
import kotlinx.serialization.serializer
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource

class SolanaRpcClient(val rpcDriver: Rpc20Driver) {
class SolanaRpcClient(
val rpcDriver: Rpc20Driver,
private val defaultTransactionOptions: TransactionOptions = TransactionOptions()
) {

constructor(url: String, networkDriver: HttpNetworkDriver): this(Rpc20Driver(url, networkDriver))
constructor(
url: String, networkDriver: HttpNetworkDriver,
defaultTransactionOptions: TransactionOptions = TransactionOptions()
) : this(Rpc20Driver(url, networkDriver), defaultTransactionOptions)

suspend inline fun <T> makeRequest(request: RpcRequest, serializer: KSerializer<T>) =
rpcDriver.makeRequest(request, serializer)

suspend inline fun <reified T> makeRequest(request: RpcRequest) =
rpcDriver.makeRequest<T>(request, serializer())

suspend fun requestAirdrop(address: SolanaPublicKey, amountSol: Float, requestId: String? = null) =
rpcDriver.makeRequest(
AirdropRequest(address, (amountSol*10f.pow(9)).toLong(), requestId),
makeRequest(
AirdropRequest(address, (amountSol * 10f.pow(9)).toLong(), requestId),
String.serializer()
)

suspend fun getBalance(address: SolanaPublicKey, commitment: String = "confirmed", requestId: String? = null) =
rpcDriver.makeRequest(BalanceRequest(address, commitment, requestId), SolanaResponseSerializer(Long.serializer()))

suspend fun getMinBalanceForRentExemption(size: Long, commitment: String? = null, requestId: String? = null) =
rpcDriver.makeRequest(RentExemptBalanceRequest(size, commitment, requestId), Long.serializer())

suspend fun getLatestBlockhash(commitment: String? = null, minContextSlot: Long? = null, requestId: String? = null) =
rpcDriver.makeRequest(LatestBlockhashRequest(commitment, minContextSlot, requestId), SolanaResponseSerializer(BlockhashResponse.serializer()))
suspend fun getBalance(
address: SolanaPublicKey,
commitment: Commitment = Commitment.CONFIRMED,
requestId: String? = null
) = makeRequest(
BalanceRequest(address, commitment, requestId),
SolanaResponseSerializer(Long.serializer())
)

suspend fun sendTransaction(transaction: Transaction, skipPreflight: Boolean = false, requestId: String? = null) =
rpcDriver.makeRequest(SendTransactionRequest(transaction, skipPreflight, requestId), String.serializer())
suspend fun getMinBalanceForRentExemption(
size: Long,
commitment: Commitment? = null,
requestId: String? = null
) = makeRequest(RentExemptBalanceRequest(size, commitment, requestId), Long.serializer())

suspend fun getLatestBlockhash(
commitment: Commitment? = null,
minContextSlot: Long? = null,
requestId: String? = null
) = makeRequest(
LatestBlockhashRequest(commitment, minContextSlot, requestId),
SolanaResponseSerializer(BlockhashResponse.serializer())
)

suspend fun sendAndConfirmTransaction(transaction: Transaction) =
sendTransaction(transaction).apply {
result?.let { confirmTransaction(it) }
}
suspend fun sendTransaction(
transaction: Transaction,
skipPreflight: Boolean = false,
requestId: String? = null
) = makeRequest(SendTransactionRequest(transaction, skipPreflight, requestId), String.serializer())

suspend fun sendAndConfirmTransaction(
transaction: Transaction,
options: TransactionOptions = defaultTransactionOptions
) = sendTransaction(transaction).apply {
result?.let { confirmTransaction(it, options) }
}

suspend fun getSignatureStatuses(signatures: List<String>, searchTransactionHistory: Boolean = false, requestId: String? = null) =
rpcDriver.makeRequest(SignatureStatusesRequest(signatures, searchTransactionHistory, requestId),
SolanaResponseSerializer(ListSerializer(SignatureStatus.serializer().nullable))
)
suspend fun getSignatureStatuses(
signatures: List<String>,
searchTransactionHistory: Boolean = false,
requestId: String? = null
) = makeRequest(
SignatureStatusesRequest(signatures, searchTransactionHistory, requestId),
SolanaResponseSerializer(ListSerializer(SignatureStatus.serializer().nullable))
)

suspend fun confirmTransaction(
signature: String,
commitment: String = "confirmed",
timeout: Long = 15000
): Result<String> = withTimeout(timeout) {
suspend fun getStatus() =
getSignatureStatuses(listOf(signature))
.result?.first()

val timeSource = TimeSource.Monotonic

// wait for desired transaction status
while(getStatus()?.confirmationStatus != commitment) {

// wait a bit before retrying
val mark = timeSource.markNow()
var inc = 0
while(mark.elapsedNow() < 0.3.seconds && isActive) { inc++ }
transactionSignature: String,
options: TransactionOptions = defaultTransactionOptions
): Result<Boolean> =
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++
}

if (!isActive) break // breakout after timeout
return@withTimeout Result.success(isActive)
}

Result.success(signature)
}

//region Requests
sealed class SolanaRpcRequest(method: String, params: JsonElement?, id: String? = null)
: JsonRpc20Request(method, params, id ?: "$method-${Random.nextInt(100000000, 999999999)}")
sealed class SolanaRpcRequest(
method: String,
params: JsonElement?,
id: String? = null
) : JsonRpc20Request(
method,
params,
id ?: "$method-${Random.nextInt(100000000, 999999999)}"
)

class AirdropRequest(address: SolanaPublicKey, lamports: Long, requestId: String? = null)
: SolanaRpcRequest(
class AirdropRequest(
address: SolanaPublicKey,
lamports: Long,
requestId: String? = null
) : SolanaRpcRequest(
method = "requestAirdrop",
params = buildJsonArray {
add(address.base58())
Expand All @@ -90,46 +147,58 @@ class SolanaRpcClient(val rpcDriver: Rpc20Driver) {
id = requestId
)

class BalanceRequest(address: SolanaPublicKey, commitment: String = "confirmed", requestId: String? = null)
: SolanaRpcRequest(
class BalanceRequest(
address: SolanaPublicKey,
commitment: Commitment = Commitment.CONFIRMED,
requestId: String? = null
) : SolanaRpcRequest(
method = "getBalance",
params = buildJsonArray {
add(address.base58())
addJsonObject {
put("commitment", commitment)
put("commitment", commitment.value)
}
},
requestId
)

class LatestBlockhashRequest(commitment: String? = null, minContextSlot: Long? = null, requestId: String? = null)
: SolanaRpcRequest(
class LatestBlockhashRequest(
commitment: Commitment? = null,
minContextSlot: Long? = null,
requestId: String? = null
) : SolanaRpcRequest(
method = "getLatestBlockhash",
params = buildJsonArray {
if (commitment != null || minContextSlot!= null) {
if (commitment != null || minContextSlot != null) {
addJsonObject {
commitment?.let { put("commitment", commitment) }
commitment?.let { put("commitment", commitment.value) }
minContextSlot?.let { put("minContextSlot", minContextSlot) }
}
}
},
requestId
)

class SendTransactionRequest(transaction: Transaction, skipPreflight: Boolean = false, requestId: String? = null)
: SolanaRpcRequest(
method = "sendTransaction",
params = buildJsonArray {
add(Base58.encodeToString(transaction.serialize()))
addJsonObject {
put("skipPreflight", skipPreflight)
}
},
requestId
)
class SendTransactionRequest(
transaction: Transaction,
skipPreflight: Boolean = false,
requestId: String? = null
) : SolanaRpcRequest(
method = "sendTransaction",
params = buildJsonArray {
add(Base58.encodeToString(transaction.serialize()))
addJsonObject {
put("skipPreflight", skipPreflight)
}
},
requestId
)

class SignatureStatusesRequest(transactionIds: List<String>, searchTransactionHistory: Boolean = false, requestId: String? = null)
: SolanaRpcRequest(
class SignatureStatusesRequest(
transactionIds: List<String>,
searchTransactionHistory: Boolean = false,
requestId: String? = null
) : SolanaRpcRequest(
method = "getSignatureStatuses",
params = buildJsonArray {
addJsonArray { transactionIds.forEach { add(it) } }
Expand All @@ -140,14 +209,17 @@ class SolanaRpcClient(val rpcDriver: Rpc20Driver) {
requestId
)

class RentExemptBalanceRequest(size: Long, commitment: String? = null, requestId: String? = null)
: SolanaRpcRequest(
class RentExemptBalanceRequest(
size: Long,
commitment: Commitment? = null,
requestId: String? = null
) : SolanaRpcRequest(
method = "getMinimumBalanceForRentExemption",
params = buildJsonArray {
add(size)
commitment?.let {
addJsonObject {
put("commitment", commitment)
put("commitment", commitment.value)
}
}
},
Expand All @@ -167,7 +239,7 @@ class SolanaRpcClient(val rpcDriver: Rpc20Driver) {
val slot: Long,
val confirmations: Long?,
var err: JsonObject?,
var confirmationStatus: String?
var confirmationStatus: Commitment?
)
//endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.solana.rpc

import kotlinx.serialization.SerialName
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

enum class Encoding(private val enc: String) {
base64("base64"),
base58("base58"),
jsonParsed("jsonParsed");
fun getEncoding(): String {
return enc
}
}

enum class Commitment(val value: String) {
@SerialName("processed")
PROCESSED("processed"),

@SerialName("confirmed")
CONFIRMED("confirmed"),

@SerialName("finalized")
FINALIZED("finalized"),

@SerialName("max")
MAX("max");

override fun toString(): String {
return 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
)
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,16 @@ class RpcClientTests {
// given
val keyPair = Ed25519.generateKeyPair()
val pubkey = SolanaPublicKey(keyPair.publicKey)
val rpc = SolanaRpcClient(TestConfig.RPC_URL, KtorNetworkDriver())
val rpc = SolanaRpcClient(TestConfig.RPC_URL, KtorNetworkDriver(),
TransactionOptions(commitment = Commitment.CONFIRMED))
val balance = 10000000L

// when
val airdropResponse = rpc.requestAirdrop(pubkey, 0.01f)

withContext(Dispatchers.Default.limitedParallelism(1)) {
rpc.confirmTransaction(airdropResponse.result!!)
rpc.confirmTransaction(airdropResponse.result!!,
TransactionOptions(commitment = Commitment.CONFIRMED))
}

val response = rpc.getBalance(pubkey)
Expand Down

0 comments on commit 2af6dd9

Please sign in to comment.