From a97c0394484d0a1d7a513f1a259a99462600eabf Mon Sep 17 00:00:00 2001 From: Marco Martinez Date: Fri, 7 Jun 2024 13:47:38 -0600 Subject: [PATCH] Add Solana System Programs (#22) * add System Program * add Memo program + cleanup * add missing JvmStatic annotation * fix memo program * add tests * add validator to ci * add transaction confirmation * start the validator on CI * edit name * run validator in bg * stop validator after test * check * remove stop step --- .github/actions/install-solana/action.yml | 39 ++++ .github/workflows/build.yml | 13 +- gradle.properties | 5 + gradle/libs.versions.toml | 6 +- solana/build.gradle.kts | 25 +++ .../kotlin/com/solana/programs/MemoProgram.kt | 18 ++ .../com/solana/programs/SystemProgram.kt | 54 +++++ .../com/solana/programs/MemoProgramTests.kt | 44 ++++ .../com/solana/programs/SystemProgramTests.kt | 100 +++++++++ .../kotlin/com/solana/util/RpcClient.kt | 197 ++++++++++++++++++ 10 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 .github/actions/install-solana/action.yml create mode 100644 solana/src/commonMain/kotlin/com/solana/programs/MemoProgram.kt create mode 100644 solana/src/commonMain/kotlin/com/solana/programs/SystemProgram.kt create mode 100644 solana/src/commonTest/kotlin/com/solana/programs/MemoProgramTests.kt create mode 100644 solana/src/commonTest/kotlin/com/solana/programs/SystemProgramTests.kt create mode 100644 solana/src/commonTest/kotlin/com/solana/util/RpcClient.kt diff --git a/.github/actions/install-solana/action.yml b/.github/actions/install-solana/action.yml new file mode 100644 index 0000000..c21ebd1 --- /dev/null +++ b/.github/actions/install-solana/action.yml @@ -0,0 +1,39 @@ +name: Install Solana + +inputs: + solana_version: + description: Version of Solana to install + required: true + +runs: + using: "composite" + steps: + - name: Cache Solana Install + if: ${{ !env.ACT }} + id: cache-solana-install + uses: actions/cache@v2 + with: + path: "$HOME/.local/share/solana/install/releases/${{ inputs.solana_version }}" + key: ${{ runner.os }}-Solana-v${{ inputs.solana_version }} + + - name: Install Solana + if: ${{ !env.ACT }} && steps.cache-solana-install.cache-hit != 'true' + run: | + sh -c "$(curl -sSfL https://release.solana.com/v${{ inputs.solana_version }}/install)" + shell: bash + + - name: Set Active Solana Version + run: | + rm -f "$HOME/.local/share/solana/install/active_release" + ln -s "$HOME/.local/share/solana/install/releases/${{ inputs.solana_version }}/solana-release" "$HOME/.local/share/solana/install/active_release" + shell: bash + + - name: Add Solana bin to Path + run: | + echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH + shell: bash + + - name: Verify Solana install + run: | + solana --version + shell: bash \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2581c78..23354d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,9 @@ on: jobs: build: runs-on: macos-latest + strategy: + matrix: + solana: ["1.18.14"] steps: - name: Checkout @@ -26,11 +29,19 @@ jobs: distribution: 'temurin' cache: gradle + - name: Install Solana + uses: ./.github/actions/install-solana + with: + solana_version: ${{ matrix.solana }} + + - name: Start local validator + run: solana-test-validator > /dev/null 2>&1 & + # Build - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Test - run: ./gradlew build + run: ./gradlew build -PlocalValidator=true - name: Save Test Reports if: failure() diff --git a/gradle.properties b/gradle.properties index 15ac47f..31e67ff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,3 +30,8 @@ POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/solana-mobile/web3-core POM_DEVELOPER_NAME=Solana Mobile Engineering POM_DEVELOPER_URL=https://solanamobile.com POM_DEVELOPER_EMAIL=eng@solanamobile.com + +# RPC URLs used for testing +testing.rpc.defaultUrl=https://api.devnet.solana.com +testing.rpc.localUrl=http://127.0.0.1:8899 +localValidator=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65861d7..a7299f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] -kotlinxCoroutines= "1.7.3" +kotlinxCoroutines = "1.7.3" +ktor = "2.3.11" # Plugin versions androidGradlePlugin = "8.0.2" @@ -8,11 +9,14 @@ kotlinSerialization = "1.6.2" vanniktechMavenPublish = "0.25.3" [libraries] +borsh = { group = "io.github.funkatronics", name = "kborsh", version = "0.1.0" } crypto = { group = "com.diglol.crypto", name = "crypto", version = "0.1.5" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerialization" } #kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } multimult = { group = "io.github.funkatronics", name = "multimult", version = "0.2.1" } rpc-core = { group = "com.solanamobile", name = "rpc-core", version = "0.2.5" } diff --git a/solana/build.gradle.kts b/solana/build.gradle.kts index 6fae560..892c173 100644 --- a/solana/build.gradle.kts +++ b/solana/build.gradle.kts @@ -31,14 +31,18 @@ kotlin { dependencies { api(project(mapOf("path" to ":core"))) implementation(libs.kotlinx.serialization.json) + implementation(libs.borsh) implementation(libs.multimult) } } val commonTest by getting { + kotlin.srcDir(File("${buildDir}/generated/src/commonTest/kotlin")) dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) implementation(libs.crypto) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) implementation(libs.rpc.core) } } @@ -48,3 +52,24 @@ kotlin { mavenPublishing { coordinates(group as String, moduleArtifactId, version as String) } + +afterEvaluate { + val defaultRpcUrl = properties["testing.rpc.defaultUrl"] + var rpcUrl = properties["rpcUrl"] ?: defaultRpcUrl + + val useLocalValidator = project.properties["localValidator"] == "true" + val localRpcUrl = project.properties["testing.rpc.localUrl"] + if (useLocalValidator && localRpcUrl != null) rpcUrl = localRpcUrl + + val dir = "${buildDir}/generated/src/commonTest/kotlin/com/solana/config" + mkdir(dir) + File(dir, "TestConfig.kt").writeText( + """ + package com.solana.config + + internal object TestConfig { + const val RPC_URL = "$rpcUrl" + } + """.trimIndent() + ) +} diff --git a/solana/src/commonMain/kotlin/com/solana/programs/MemoProgram.kt b/solana/src/commonMain/kotlin/com/solana/programs/MemoProgram.kt new file mode 100644 index 0000000..5f21b99 --- /dev/null +++ b/solana/src/commonMain/kotlin/com/solana/programs/MemoProgram.kt @@ -0,0 +1,18 @@ +package com.solana.programs + +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.AccountMeta +import com.solana.transaction.TransactionInstruction +import kotlin.jvm.JvmStatic + +object MemoProgram { + @JvmStatic + val PROGRAM_ID = SolanaPublicKey.from("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") + + @JvmStatic + fun publishMemo(account: SolanaPublicKey, memo: String): TransactionInstruction = + TransactionInstruction(PROGRAM_ID, + listOf(AccountMeta(account, true, true)), + memo.encodeToByteArray() + ) +} \ No newline at end of file diff --git a/solana/src/commonMain/kotlin/com/solana/programs/SystemProgram.kt b/solana/src/commonMain/kotlin/com/solana/programs/SystemProgram.kt new file mode 100644 index 0000000..2011446 --- /dev/null +++ b/solana/src/commonMain/kotlin/com/solana/programs/SystemProgram.kt @@ -0,0 +1,54 @@ +package com.solana.programs + +import com.funkatronics.kborsh.BorshEncoder +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.AccountMeta +import com.solana.transaction.TransactionInstruction +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlin.jvm.JvmStatic + +object SystemProgram { + @JvmStatic + val PROGRAM_ID = SolanaPublicKey.from("11111111111111111111111111111111") + + private const val PROGRAM_INDEX_CREATE_ACCOUNT = 0 + private const val PROGRAM_INDEX_TRANSFER = 2 + + @JvmStatic + fun transfer( + fromPublicKey: SolanaPublicKey, + toPublickKey: SolanaPublicKey, + lamports: Long + ): TransactionInstruction = + TransactionInstruction(PROGRAM_ID, + listOf( + AccountMeta(fromPublicKey, true, true), + AccountMeta(toPublickKey, false, true) + ), + BorshEncoder().apply { + encodeInt(PROGRAM_INDEX_TRANSFER) + encodeLong(lamports) + }.borshEncodedBytes + ) + + @JvmStatic + fun createAccount( + fromPublicKey: SolanaPublicKey, + newAccountPublickey: SolanaPublicKey, + lamports: Long, + space: Long, + programId: SolanaPublicKey + ): TransactionInstruction = + TransactionInstruction(PROGRAM_ID, + listOf( + AccountMeta(fromPublicKey, true, true), + AccountMeta(newAccountPublickey, true, true) + ), + BorshEncoder().apply { + encodeInt(PROGRAM_INDEX_CREATE_ACCOUNT) + encodeLong(lamports) + encodeLong(space) + encodeSerializableValue(ByteArraySerializer(), programId.bytes) + }.borshEncodedBytes + ) +} \ No newline at end of file diff --git a/solana/src/commonTest/kotlin/com/solana/programs/MemoProgramTests.kt b/solana/src/commonTest/kotlin/com/solana/programs/MemoProgramTests.kt new file mode 100644 index 0000000..9092425 --- /dev/null +++ b/solana/src/commonTest/kotlin/com/solana/programs/MemoProgramTests.kt @@ -0,0 +1,44 @@ +package com.solana.programs + +import com.solana.config.TestConfig +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.Message +import com.solana.transaction.Transaction +import com.solana.util.RpcClient +import diglol.crypto.Ed25519 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MemoProgramTests { + + @Test + fun `publishMemo builds valid transaction`() = runTest { + // given + val keyPair = Ed25519.generateKeyPair() + val pubkey = SolanaPublicKey(keyPair.publicKey) + val rpc = RpcClient(TestConfig.RPC_URL) + val message = "hello solana!" + + // when + val airdropResponse = rpc.requestAirdrop(pubkey, 0.1f) + val blockhashResponse = rpc.getLatestBlockhash() + + val transaction = Message.Builder() + .setRecentBlockhash(blockhashResponse.result!!.blockhash) + .addInstruction(MemoProgram.publishMemo(pubkey, message)) + .build().run { + val sig = Ed25519.sign(keyPair, serialize()) + Transaction(listOf(sig), this) + } + + val response = rpc.sendTransaction(transaction) + + // then + assertNull(airdropResponse.error) + assertNotNull(airdropResponse.result) + assertNull(response.error) + assertNotNull(response.result) + } +} \ No newline at end of file diff --git a/solana/src/commonTest/kotlin/com/solana/programs/SystemProgramTests.kt b/solana/src/commonTest/kotlin/com/solana/programs/SystemProgramTests.kt new file mode 100644 index 0000000..cd7b761 --- /dev/null +++ b/solana/src/commonTest/kotlin/com/solana/programs/SystemProgramTests.kt @@ -0,0 +1,100 @@ +package com.solana.programs + +import com.solana.config.TestConfig +import com.solana.publickey.SolanaPublicKey +import com.solana.transaction.Message +import com.solana.transaction.Transaction +import com.solana.util.RpcClient +import diglol.crypto.Ed25519 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SystemProgramTests { + + @Test + fun `createAccount successfully creates account`() = runTest { + // given + val payerKeyPair = Ed25519.generateKeyPair() + val newAccountKeyPair = Ed25519.generateKeyPair() + val payerPubkey = SolanaPublicKey(payerKeyPair.publicKey) + val newAccountPubkey = SolanaPublicKey(newAccountKeyPair.publicKey) + val rpc = RpcClient(TestConfig.RPC_URL) + + // when + val airdropResponse = rpc.requestAirdrop(payerPubkey, 0.1f) + + val rentExemptBalanceResponse = rpc.getMinBalanceForRentExemption(0) + + val blockhashResponse = rpc.getLatestBlockhash() + + val transaction = Message.Builder() + .setRecentBlockhash(blockhashResponse.result!!.blockhash) + .addInstruction(SystemProgram.createAccount(payerPubkey, + newAccountPubkey, + rentExemptBalanceResponse.result!!, + 0L, + SystemProgram.PROGRAM_ID + )) + .build().run { + val payerSig = Ed25519.sign(payerKeyPair, serialize()) + val newAccountSig = Ed25519.sign(newAccountKeyPair, serialize()) + Transaction(listOf(payerSig, newAccountSig), this) + } + + withContext(Dispatchers.Default.limitedParallelism(1)) { + rpc.sendAndConfirmTransaction(transaction) + } + + val response = rpc.getBalance(newAccountPubkey) + + // then + assertNull(airdropResponse.error) + assertNotNull(airdropResponse.result) + assertNull(response.error) + assertNotNull(response.result) + assertEquals(rentExemptBalanceResponse.result!!, response.result) + } + + @Test + fun `transfer successfully transfers funds`() = runTest { + // given + val payerKeyPair = Ed25519.generateKeyPair() + val receiverKeyPair = Ed25519.generateKeyPair() + val payerPubkey = SolanaPublicKey(payerKeyPair.publicKey) + val receiverPubkey = SolanaPublicKey(receiverKeyPair.publicKey) + val rpc = RpcClient(TestConfig.RPC_URL) + val balance = 10000000L // lamports + + // when + val airdropResponse = rpc.requestAirdrop(payerPubkey, 0.1f) + val blockhashResponse = rpc.getLatestBlockhash() + + val transaction = Message.Builder() + .setRecentBlockhash(blockhashResponse.result!!.blockhash) + .addInstruction(SystemProgram.transfer(payerPubkey, receiverPubkey, balance)) + .build().run { + val sig = Ed25519.sign(payerKeyPair, serialize()) + Transaction(listOf(sig), this) + } + + withContext(Dispatchers.Default.limitedParallelism(1)) { + rpc.sendAndConfirmTransaction(transaction) + } + + val response = rpc.getBalance(receiverPubkey) + + // then + assertNull(airdropResponse.error) + assertNotNull(airdropResponse.result) + assertNull(response.error) + assertNotNull(response.result) + assertEquals(balance, response.result!!) + } +} \ No newline at end of file diff --git a/solana/src/commonTest/kotlin/com/solana/util/RpcClient.kt b/solana/src/commonTest/kotlin/com/solana/util/RpcClient.kt new file mode 100644 index 0000000..42741bc --- /dev/null +++ b/solana/src/commonTest/kotlin/com/solana/util/RpcClient.kt @@ -0,0 +1,197 @@ +package com.solana.util + +import com.funkatronics.encoders.Base58 +import com.solana.networking.HttpNetworkDriver +import com.solana.networking.HttpRequest +import com.solana.networking.Rpc20Driver +import com.solana.publickey.SolanaPublicKey +import com.solana.rpccore.JsonRpc20Request +import com.solana.transaction.Transaction +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.date.* +import io.ktor.utils.io.core.* +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.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import kotlin.math.pow + +class RpcClient(val rpcDriver: Rpc20Driver) { + + constructor(url: String, networkDriver: HttpNetworkDriver = KtorHttpDriver()): this(Rpc20Driver(url, networkDriver)) + + suspend fun requestAirdrop(address: SolanaPublicKey, amountSol: Float) = + rpcDriver.makeRequest( + AirdropRequest(address, (amountSol*10f.pow(9)).toLong()), + String.serializer() + ) + + suspend fun getBalance(address: SolanaPublicKey, commitment: String = "confirmed") = + rpcDriver.makeRequest(BalanceRequest(address, commitment), SolanaResponseSerializer(Long.serializer())) + + suspend fun getMinBalanceForRentExemption(size: Long, commitment: String? = null) = + rpcDriver.makeRequest(RentExemptBalanceRequest(size, commitment), Long.serializer()) + + suspend fun getLatestBlockhash() = + rpcDriver.makeRequest(LatestBlockhashRequest(), SolanaResponseSerializer(BlockhashResponse.serializer())) + + suspend fun sendTransaction(transaction: Transaction) = + rpcDriver.makeRequest(SendTransactionRequest(transaction), String.serializer()) + + suspend fun sendAndConfirmTransaction(transaction: Transaction) = + sendTransaction(transaction).apply { + result?.let { confirmTransaction(it) } + } + + suspend fun getSignatureStatuses(signatures: List) = + rpcDriver.makeRequest(SignatureStatusesRequest(signatures), + SolanaResponseSerializer(ListSerializer(SignatureStatus.serializer().nullable))) + + suspend fun confirmTransaction( + signature: String, + commitment: String = "confirmed", + timeout: Long = 15000 + ): Result = withTimeout(timeout) { + suspend fun getStatus() = + getSignatureStatuses(listOf(signature)) + .result?.first() + + // wait for desired transaction status + while(getStatus()?.confirmationStatus != commitment) { + + // wait a bit before retrying + val millis = getTimeMillis() + var inc = 0 + while(getTimeMillis() - millis < 300 && isActive) { inc++ } + + if (!isActive) break // breakout after timeout + } + + Result.success(signature) + } + + class SolanaResponseSerializer(dataSerializer: KSerializer) + : KSerializer { + private val serializer = WrappedValue.serializer(dataSerializer) + override val descriptor: SerialDescriptor = serializer.descriptor + + override fun serialize(encoder: Encoder, value: R?) = + encoder.encodeSerializableValue(serializer, WrappedValue(value)) + + override fun deserialize(decoder: Decoder): R? = + decoder.decodeSerializableValue(serializer).value + } + + @Serializable + class WrappedValue(val value: V?) + + class KtorHttpDriver : HttpNetworkDriver { + override suspend fun makeHttpRequest(request: HttpRequest): String = + HttpClient().use { client -> + client.request(request.url) { + method = HttpMethod.parse(request.method) + request.properties.forEach { (k, v) -> + header(k, v) + } + setBody(request.body) + }.bodyAsText().apply { + println(this) + } + } + } + + class AirdropRequest(address: SolanaPublicKey, lamports: Long, requestId: String = "1") + : JsonRpc20Request( + method = "requestAirdrop", + params = buildJsonArray { + add(address.base58()) + add(lamports) + }, + id = requestId + ) + + class BalanceRequest(address: SolanaPublicKey, commitment: String = "confirmed", requestId: String = "1") + : JsonRpc20Request( + method = "getBalance", + params = buildJsonArray { + add(address.base58()) + addJsonObject { + put("commitment", commitment) + } + }, + requestId + ) + + class LatestBlockhashRequest(commitment: String = "confirmed", requestId: String = "1") + : JsonRpc20Request( + method = "getLatestBlockhash", + params = buildJsonArray { + addJsonObject { + put("commitment", commitment) + } + }, + requestId + ) + + @Serializable + class BlockhashResponse( + val blockhash: String, + val lastValidBlockHeight: Long + ) + + class SendTransactionRequest(transaction: Transaction, skipPreflight: Boolean = true, requestId: String = "1") + : JsonRpc20Request( + method = "sendTransaction", + params = buildJsonArray { + add(Base58.encodeToString(transaction.serialize())) + addJsonObject { + put("skipPreflight", skipPreflight) + } + }, + requestId + ) + + class SignatureStatusesRequest(transactionIds: List, searchTransactionHistory: Boolean = false, requestId: String = "1") + : JsonRpc20Request( + method = "getSignatureStatuses", + params = buildJsonArray { + addJsonArray { transactionIds.forEach { add(it) } } + addJsonObject { + put("searchTransactionHistory", searchTransactionHistory) + } + }, + requestId + ) + + @Serializable + data class SignatureStatus( + val slot: Long, + val confirmations: Long?, + var err: JsonObject?, + var confirmationStatus: String? + ) + + class RentExemptBalanceRequest(size: Long, commitment: String? = null, requestId: String = "1") + : JsonRpc20Request( + method = "getMinimumBalanceForRentExemption", + params = buildJsonArray { + add(size) + commitment?.let { + addJsonObject { + put("commitment", commitment) + } + } + }, + requestId + ) +} \ No newline at end of file