Example of a todo application that is using Solana blockchain for storage. One of the cool things about Solana is that it supports writing onchain programs in Rust so we can reuse program's types in the offchain prest code to simplify interactions. Also, there is an Anchor framework that simplifies smart contract development by abstracting onchain accounts so that we don't have to worry all technical details. To get started we'll need to add anchor dependencies and some patches to make it compatible with prest:
Cargo.toml
[workspace]
members = ["."]
resolver = "2"
[package]
name = "todo-solana"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[target.'cfg(target_os = "solana")'.dependencies]
anchor-lang = { version = "0.30.1", features = ["init-if-needed"] }
[target.'cfg(not(target_os = "solana"))'.dependencies]
anchor-lang = { version = "0.30.1", features = ["init-if-needed"] }
anchor-client = { version = "0.30.1", features = ["async"] }
prest = { path = "../../", version = "0.4" }
[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1
[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
# until https://github.com/coral-xyz/anchor/pull/3057 is released
[patch.crates-io.anchor-client]
git = "https://github.com/coral-xyz/anchor.git"
rev = "f677742a978ffdf7bc321746b4119394f6654b7c"
[patch.crates-io.anchor-lang]
git = "https://github.com/coral-xyz/anchor.git"
rev = "f677742a978ffdf7bc321746b4119394f6654b7c"
# dependencies conflicts, should be resolved with solana-program v2
[patch.crates-io.curve25519-dalek]
git = "https://github.com/solana-labs/curve25519-dalek.git"
rev = "b500cdc2a920cd5bff9e2dd974d7b97349d61464"
[patch.crates-io.aes-gcm-siv]
git = "https://github.com/edezhic/AEADs"
You might also notice profile overrides which are needed to make program's code as small as possible because onchain storage is quite expensive.
Next comes the program's code:
src/lib.rs
use anchor_lang::prelude::*;
declare_id!("S7SsyD8YqKtPzZS6pRButF658jCDnX5KvoU6kFQwKWH");
#[account]
#[derive(InitSpace)]
pub struct TodoList {
#[max_len(5)]
pub items: Vec<Todo>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq, InitSpace)]
pub struct Todo {
#[max_len(20)]
pub task: String,
pub done: bool,
}
#[program]
pub mod todo_solana {
use super::*;
#[derive(Accounts)]
#[instruction(task: String)]
pub struct AddTodo<'info> {
#[account(
init_if_needed,
seeds = [owner.key().as_ref()],
bump,
payer = owner,
space = 8 + TodoList::INIT_SPACE,
)]
pub list: Account<'info, TodoList>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn add_todo(ctx: Context<AddTodo>, task: String) -> Result<()> {
ctx.accounts.list.items.push(Todo { task, done: false });
Ok(())
}
#[derive(Accounts)]
#[instruction(index: u32)]
pub struct ToggleTodo<'info> {
#[account(
mut,
seeds = [owner.key().as_ref()],
bump,
)]
pub list: Account<'info, TodoList>,
#[account(mut)]
pub owner: Signer<'info>,
}
pub fn toggle_todo(ctx: Context<ToggleTodo>, index: u32) -> Result<()> {
let index = index as usize;
require!(ctx.accounts.list.items.get(index).is_some(), TodoError::NotFound);
let todo = ctx.accounts.list.items.get_mut(index).unwrap();
todo.done = !todo.done;
Ok(())
}
#[derive(Accounts)]
#[instruction(index: u32)]
pub struct DeleteTodo<'info> {
#[account(
mut,
seeds = [owner.key().as_ref()],
bump,
)]
pub list: Account<'info, TodoList>,
#[account(mut)]
pub owner: Signer<'info>,
}
pub fn delete_todo(ctx: Context<DeleteTodo>, index: u32) -> Result<()> {
let index = index as usize;
require!(ctx.accounts.list.items.get(index).is_some(), TodoError::NotFound);
ctx.accounts.list.items.remove(index);
Ok(())
}
#[error_code]
pub enum TodoError {
NotFound
}
}
Here we have definitions for the onchain data and available instructions. Each instruction requires a context which defines accounts that are needed for the execution. Some of them like Signer
and System
are built-in, but TodoList
is defined by this program.
Last piece is the application which will prepare and deploy the program to the local network and allow us to interact with it:
src/main.rs
#![allow(dead_code)]
use prest::*;
use anchor_client::{
solana_sdk::{
pubkey::Pubkey,
signature::{Keypair, Signer},
system_program,
}, Client, ClientError, Cluster::Localnet, Program
};
use std::process::Command;
use todo_solana::{accounts, instruction, TodoList, ID as PROGRAM_ID};
state!(KEYPAIR: Arc<Keypair> = { Arc::new(Keypair::new()) });
state!(PROGRAM: Program<Arc<Keypair>> = {
Client::new(Localnet, KEYPAIR.clone()).program(PROGRAM_ID)?
});
state!(TODO_LIST_PDA: Pubkey = { Pubkey::find_program_address(&[KEYPAIR.pubkey().as_ref()], &PROGRAM.id()).0 });
fn main() {
init!();
setup_local_solana_environment();
route("/", get(list).post(add))
.route("/toggle/:index", get(toggle))
.route("/delete/:index", get(delete))
.wrap_non_htmx(into_page)
.run()
}
fn setup_local_solana_environment() {
if let Err(e) = Command::new("solana").arg("-v").output() {
const INSTALL_SOLANA_UNIX: &str =
r#"sh -c "$(curl -sSfL https://release.solana.com/v1.18.18/install)""#;
const DOWNLOAD_SOLANA_WINDOWS: &str = r#"cmd /c "curl https://release.solana.com/v1.18.18/solana-install-init-x86_64-pc-windows-msvc.exe --output C:\solana-install-tmp\solana-install-init.exe --create-dirs""#;
const INSTALL_SOLANA_WINDOWS: &str =
r#"C:\solana-install-tmp\solana-install-init.exe v1.18.18"#;
let err =
format!("{e}\nLooks like Solana CLI is not installed, you can install it with:\n");
#[cfg(target_os = "windows")]
error!("{err}{DOWNLOAD_SOLANA_WINDOWS}\nand\n{INSTALL_SOLANA_WINDOWS}");
#[cfg(not(target_os = "windows"))]
error!("{err}{INSTALL_SOLANA_UNIX}");
return;
}
std::thread::spawn(|| {
Command::new("solana-test-validator")
.args(&["--reset", "--ledger", "./target/ledger"])
.stdout(std::process::Stdio::null())
.spawn()
.expect("Failed to start solana-test-validator")
});
info!("Awaiting local cluster start...");
std::thread::sleep(std::time::Duration::from_secs(5));
info!("Building the program...");
Command::new("cargo")
.args(&["build-sbf", "--", "--lib"])
.output()
.expect("Program should build successfully");
info!("Deploying the program...");
Command::new("solana")
.args(&["program", "deploy", "./target/deploy/todo_solana.so"])
.output()
.expect("Program deploy should be successful");
info!("Airdropping SOL to the test keypair...");
Command::new("solana")
.args(&["airdrop", "10", &KEYPAIR.pubkey().to_string()])
.args(&["--commitment", "finalized"])
.output()
.expect("Successful SOL airdrop");
}
async fn list() -> Markup {
let my_todos = match PROGRAM.account::<TodoList>(*TODO_LIST_PDA).await {
Ok(list) => list.items,
Err(ClientError::AccountNotFound) => vec![],
Err(e) => {
return html!{"Can't get todo list:" br; code{(e)}};
}
};
html!(@for (index, todo) in my_todos.iter().enumerate() {
$"flex justify-between items-center" into="#list" {
input type="checkbox" get=(format!("/toggle/{index}")) checked[todo.done] {}
label $"ml-4 text-lg" {(todo.task)}
button $"ml-auto" get=(format!("/delete/{index}")) {"Delete"}
}
})
}
async fn into_page(content: Markup) -> Markup {
html! {(DOCTYPE) html {(Head::with_title("With Solana storage"))
body $"max-w-screen-sm px-8 mx-auto mt-12 flex flex-col items-center" {
form method="POST" into="#list" after-request="this.reset()" {
input $"border rounded-md" type="text" name="task" {}
button $"ml-4" type="submit" {"Add"}
}
div #list $"w-full" {(content)}
(Scripts::default())
}
}}
}
#[derive(Serialize, Deserialize)]
struct NewTodo {
task: String,
}
async fn add(Vals(todo): Vals<NewTodo>) -> Markup {
if let Err(e) = PROGRAM
.request()
.accounts(accounts::AddTodo {
list: *TODO_LIST_PDA,
owner: PROGRAM.payer(),
system_program: system_program::ID,
})
.args(instruction::AddTodo { task: todo.task })
.send()
.await
{
error!("couldn't create todo: {e}");
};
list().await
}
async fn toggle(Path(index): Path<u32>) -> Markup {
if let Err(e) = PROGRAM
.request()
.accounts(accounts::ToggleTodo {
list: *TODO_LIST_PDA,
owner: PROGRAM.payer(),
})
.args(instruction::ToggleTodo { index })
.send()
.await
{
error!("couldn't toggle todo: {e}");
};
list().await
}
async fn delete(Path(index): Path<u32>) -> Markup {
if let Err(e) = PROGRAM
.request()
.accounts(accounts::DeleteTodo {
list: *TODO_LIST_PDA,
owner: PROGRAM.payer(),
})
.args(instruction::DeleteTodo { index })
.send()
.await
{
error!("couldn't delete todo: {e}");
};
list().await
}
This example is hardcoded for an easy local setup and demo purposes, but overall solana interactions aren't much different. However, in a real project you'll probably want to run transactions from the frontend signed by users' keys, and current solana sdks do not support doing that in rust, so you'll probably need to add some javascript.