Skip to content

Commit

Permalink
Add Solana System Programs (#22)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Funkatronics authored Jun 7, 2024
1 parent 836337b commit a97c039
Show file tree
Hide file tree
Showing 10 changed files with 499 additions and 2 deletions.
39 changes: 39 additions & 0 deletions .github/actions/install-solana/action.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
jobs:
build:
runs-on: macos-latest
strategy:
matrix:
solana: ["1.18.14"]

steps:
- name: Checkout
Expand All @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ POM_SCM_DEV_CONNECTION=scm:git:ssh://[email protected]/solana-mobile/web3-core
POM_DEVELOPER_NAME=Solana Mobile Engineering
POM_DEVELOPER_URL=https://solanamobile.com
POM_DEVELOPER_EMAIL=[email protected]

# RPC URLs used for testing
testing.rpc.defaultUrl=https://api.devnet.solana.com
testing.rpc.localUrl=http://127.0.0.1:8899
localValidator=false
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[versions]
kotlinxCoroutines= "1.7.3"
kotlinxCoroutines = "1.7.3"
ktor = "2.3.11"

# Plugin versions
androidGradlePlugin = "8.0.2"
Expand All @@ -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" }

Expand Down
25 changes: 25 additions & 0 deletions solana/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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()
)
}
18 changes: 18 additions & 0 deletions solana/src/commonMain/kotlin/com/solana/programs/MemoProgram.kt
Original file line number Diff line number Diff line change
@@ -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()
)
}
54 changes: 54 additions & 0 deletions solana/src/commonMain/kotlin/com/solana/programs/SystemProgram.kt
Original file line number Diff line number Diff line change
@@ -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
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
100 changes: 100 additions & 0 deletions solana/src/commonTest/kotlin/com/solana/programs/SystemProgramTests.kt
Original file line number Diff line number Diff line change
@@ -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!!)
}
}
Loading

0 comments on commit a97c039

Please sign in to comment.