crystalana
crystalana
Write Solana programs in Crystal. Yes, really.
Crystal → LLVM IR → retarget bpfel → sBPF → on-chain execution
⚠️ This is an unaudited, experimental proof of concept. It exists to prove a point, not to hold funds. Do not use it in production. Do not use it near production. Educational purposes only.
Proof it runs (local test validator, invoked from Ruby — Crystal on-chain, Ruby off-chain, as nature intended):
$ solana program deploy build/program.so
Program Id: 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2
$ ruby invoke.rb 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2 1 2 3
signature: APH7rBTwAbMvfr25RjRftnrNmdhQ9v2SxzyKmLuAr1EQZHeZXo1CkuauT5FAqtZ348Ecyekmz9NtXWa18mBfobB
on-chain logs:
Program 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2 invoke [1]
Program log: Hello from Crystalana!
Program log: 0x0, 0x0, 0x0, 0x0, 0x3
Program log: 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2
Program 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2 consumed 330 of 200000 compute units
Program 6CVkPxXChCrgCnwwDKkaaM1CCuQaB7aQKbpLY2bqJZP2 success
Those three log lines are: a hello, the instruction data length (0x3 — we sent three bytes) read by walking the runtime's serialized input buffer zero-copy (accounts, dup markers, realloc padding and all, per pinocchio's layout), and the program's own id read from the end of that buffer. The entire on-chain program is one small Crystal file and costs 330 compute units. Zero Solana SDK dependencies. No GC, no exceptions, no libc, no runtime.
How it works
Crystal can't target BPF. Its codegen whitelist stops at x86/aarch64/arm/avr/wasm32 — --target bpfel-unknown-none is rejected. But Crystal has a native LLVM frontend, so we compile for x86_64-unknown-linux-gnu (64-bit little-endian, same as BPF), take the emitted .ll, rewrite the target datalayout/target triple header to bpfel and strip host-specific function attributes (tooling/retarget_ir.py). Then llvm-as → sbpf-linker turns the bitcode into a deployable SBPF V0 .so. Same trick as swiftana, minus the custom LLVM pass — Crystal's fun already gives C calling convention and an unmangled entrypoint symbol, so plain text surgery suffices. (Stick to --override-cpu-flag=v2: v3 lets LLVM emit JMP32 instructions that sbpf-linker 0.1.9's own bytecode parser can't decode — see NOTES.md.)
No runtime, by prelude. Crystal's default prelude drags in a GC, exception unwinding, and libc. src/prelude_sbpf.cr replaces it with require "primitives" plus two pointer helpers — the compiler's built-in types with only the @[Primitive] methods attached. No fun main in the prelude means no main in the module. The price: wrapping arithmetic only (&+), no heap, no raise, no mutable globals. For a Solana program that's not a limitation, it's the spec.
Syscalls resolve by name. The program declares lib LibSol; fun sol_log_(msg : UInt8*, len : UInt64); end and calls it. sbpf-linker recognizes registered syscall names and emits a R_SBF_SYSCALL relocation the SVM resolves at load time — no (void*)0x207559bd murmur3-hash function pointers needed (that constant is murmur3_32("sol_log_"), the mechanism under the name resolution). Gotcha that cost an hour: the syscall must be listed in --export entrypoint,sol_log_. Otherwise bpf-linker internalizes the declaration, dead-argument-elimination strips the call's arguments, and DCE deletes your entire message. The linker exits 0. The program logs nothing.
No string literals. sbpf-linker rebuilds .rodata from named symbols and Crystal's string constants don't qualify, so the message is materialized on the stack at compile time by a macro:
{% for c, i in "Hello from Crystalana!".chars %}
(p + {{ i }}_i64).value = {{ c.ord }}_u8
{% end %}
The string exists only at macro expansion; the IR is 22 constant byte stores, which LLVM cheerfully merges into wide stores whose immediates spell the message in the disassembly.
Build & run
Pinned toolchain (what this was built with): Crystal 1.20.3, LLVM 22.1.8, sbpf-linker 0.1.9, solana-cli 2.2.20, Rust ≥ 1.90 (to build the linker). One rule: the same LLVM major everywhere — Crystal's bundled LLVM, opt/llvm-as, and the LLVM sbpf-linker is built against.
macOS (Apple Silicon, tested):
brew install crystal llvm # crystal 1.20.3 pulls llvm 22.x anyway
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
LLVM_PREFIX=/opt/homebrew/opt/llvm LLVM_SYS_221_PREFIX=/opt/homebrew/opt/llvm \
cargo install sbpf-linker --no-default-features --features bpf-linker/llvm-22
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" # solana CLI
Ubuntu (untested, should work):
wget https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh 22
sudo apt-get install llvm-22-dev libclang-22-dev libpolly-22-dev
# crystal via https://crystal-lang.org/install/, rustup + sbpf-linker + solana CLI as above
# (LLVM_PREFIX=/usr/lib/llvm-22, LLVM_BIN=/usr/lib/llvm-22/bin for make)
Then:
make build # → build/program.so
make test # Mollusk SVM integration test, no validator needed
make objdump # stare at the sBPF disassembly
solana-test-validator # separate terminal
solana config set --url localhost && solana airdrop 500
make deploy # prints Program Id
gem install solana-ruby-web3js # needs: brew install libsodium
ruby invoke.rb <PROGRAM_ID>
make golden rebuilds clana's C entrypoint as known-good BPF IR — when the Crystal IR misbehaves, diff against it instead of guessing.
Lineage
This joins the "language X on Solana" family, all enabled by sbpf-linker (Blueshift): clana (C), nimlana (Nim), swiftana (Swift), golang-lana (Go), javalana (Java), pylana (Python), C-Nocchio (C, Pinocchio-style). Crystal's slot was empty. Not anymore.
License
MIT
crystalana
- 0
- 0
- 0
- 0
- 0
- about 4 hours ago
- July 11, 2026
MIT License
Sat, 11 Jul 2026 12:02:14 GMT