Introduction
Poseidon
is a transpiler that helps you to convert your TypeScript Solana programs into Anchor, which is especially convenient for people who are just getting started with Solana!
Now you can write Solana programs in Typescript and transpile them into Rust using Poseidon!
Installation
Make sure you have Rust and Cargo installed
Installing with Cargo
cargo install --git https://github.com/Turbin3/poseidon
That's it, you're done!
Installing from source
git clone https://github.com/Turbin3/poseidon
Navigate to the project directory:
cd poseidon
Build poseidon
:
cargo build --release
This will create a binary named poseidon
in the target/release
directory. You can copy the binary to a location in your $PATH
for easier access.
Usage
poseidon compile --input "input.ts" --output "output.rs"
Check out examples in the repo to learn how to write Poseidon Typescript which can be transpiled to Anchor programs.
Examples
-
Vote (Rust, TypeScript)
-
Vault (Rust, TypeScript)
-
Escrow (Rust, TypeScript)
-
Favorites (Rust, TypeScript)
Tutorial
Overview
This tutorial is for people with no experience in Rust who want to quickly write a Solana program in TypeScript. Poseidon will help you transpile your TypeScript code into Anchor (a Solana framework), allowing you to understand how Solana works through practical examples.
Please note that if your goal is to become a protocol engineer on Solana, you'll eventually need to learn Anchor and Rust to understand how Solana works at a lower level.
Without further ado, let’s get your hands dirty!
Environment Setup
Prerequisites
If you’ve already install Solana and Anchor, feel free to skip
prerequisites
part
During this tutorial, we will be using the following tools:
$ rustup --version
rustup 1.27.1 (54dd3d00f 2024-04-24)
$ solana --version
solana-cli 1.18.17 (src:b685182a; feat:4215500110, client:SolanaLabs)
$ yarn --version
1.22.19
$ anchor --version
anchor-cli 0.30.1
If you haven't installed all of them yet, go to Solana Anchor Installation Guide
Install Poseidon
git clone [email protected]:Turbin3/poseidon.git
cd poseidon
# Build poseidon binary file
cargo build --release
You can copy poseidon
from the target/release
folder to your PATH or update your PATH in your profile file (~/.bash_profile
, ~/.zshrc
, ~/.profile
, or ~/.bashrc
) for future use.
To finish this tutorial, you can simply create an alias:
$ pwd
/path/to/poseidon/project
$ alias poseidon='/path/to/poseidon/project/target/release/poseidon'
# Check poseidon command works as expected
$ poseidon --help
Congratulations! You’ve completed the most challenging part! Setting up the environment can be a hassle, but once it's done, the rest will be much simpler and easier.
Your First Solana Program with TypeScript
We’ll build a simple vote program with three instructions:
initialize
,upvote
, anddownvote
.
Remember what Poseidon does for you? Here’s a quick recap:
Poseidon helps by transpiling your TypeScript code into Anchor.
Let’s use poseidon init
to set up a scaffold, and then we can start writing our program in TypeScript.
# Feel free to switch to whereever you preferred.
$ mkdir tutorial
$ cd tutorial
$ poseidon init vote-program
Open vote-program/ts-programs/voteProgram.ts
in VS Code (or any IDE you prefer) and add the initial pieces of code (without the logic).
import { Account, Pubkey, type Result, i64, u8, Signer } from "@solanaturbine/poseidon";
export default class VoteProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
initialize(): Result {}
upvote(): Result {}
downvote(): Result {}
}
As we mentioned at the beginning, this program will contain only three simple instructions (initialize
, upvote
, downvote
). Here’s how it looks when using Poseidon.
In Solana, programs are stateless, meaning the functions above are “pure functions”—you get the same output from the same input. But something is missing in the code, what it is?
State!
Ultimately, we need a place to store our voting results, just like storing data in a database in Web2. In Solana, we called it “Account.” Let’s add the account at the end of our program.
// ...
export interface VoteState extends Account {
vote: i64; // This field store the voting result
bump: u8; // bump is for PDA (program derieved account, a special type of account which controlled by program on Solana)
}
i64
stands for signed integer with 64 bit and u8
stands for unsigned integer with 8 bit in Rust.
We’ll use the vote
field to store the voting result, and we can ignore the bump
field for now. You can find more information about it in the reference section after completing this tutorial.
We’ve defined the VoteState
account as our data structure, and now we're ready to implement the logic inside each instruction. Let’s start with the initialize
instruction:
// Pass all the accounts we need as the parameters
initialize(state: VoteState, user: Signer): Result {
// Use `.derive([seed])` to define the PDA and chain the `.init(payer)` at the end for creating the account and pass the payer argument
state.derive(["vote"])
.init(user);
// Set the initial value to the `vote` field of the account
state.vote = new i64(0);
}
If a user wants to store anything on Solana, such as VoteState
in this case, they’ll need to pay rent for the space they’re using, as validators need to store the data on their hardware. To cover this rent, we add user
with the Signer
type as a parameter, allowing the user to transfer their SOL to the VoteState
account to pay for the rent.
We’ve mentioned PDA several times, but what is it? PDA (Program Derived Address) is an important concept on Solana. It allows an account to be controlled by a specified program. To construct a PDA, you need a seed—a byte array that can be derived from a string, public key, integer, or even combinations of these! In this case, we use the string “vote”
as the seed. You can find more examples of different seed combinations in the provided examples.
After the state account is initialized, we can assign an initial value, new i64(0)
, to it.
We’re almost done. Let’s update the upvote
and downvote
instructions:
upvote(state: VoteState): Result {
state.derive(["vote"]);
state.vote = state.vote.add(1);
}
downvote(state: VoteState): Result {
state.derive(["vote"]);
state.vote = state.vote.sub(1);
}
Every time you use a PDA, you’ll need to specify its seed, but only when creating the account do you need to chain the init()
at the end.
When you're initializing account, Poseidon automatically adds the SystemProgram account to the account struct. Similarly in examples given in the repo, we can see that it also automatically adds Token Program and Associated Token Program accounts.
The logic for upvote
and downvote
is quite simple—just add or subtract by 1. The only thing to be aware of is that you need to assign the result back to where it’s stored, e.g. state.vote
. Otherwise, the value won’t be updated after the instruction is executed.
The final step to complete this program is to run the command below to get your correct program ID and replace, if the program ID is not synced yet.
$ poseidon sync
Test Your Program!
It’s time to verify that the program works as expected! Let’s use the Poseidon command with Anchor to make the magic happen 😉 If you type poseidon --help
in your terminal, you’ll see:
poseidon --help
Usage: poseidon <COMMAND>
Commands:
build Build Typescript programs in workspace
test Run anchor tests in the workspace
sync Sync anchor keys in poseidon programs
compile Transpile a Typescript program to a Rust program
init Initializes a new workspace
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Obviously, we’ll use the TypeScript code to generate and replace the Rust code that Anchor generated for us. If you’ve followed this tutorial step-by-step, your program structure (under the tutorial/vote_program
folder) should look like this:
.
├── Anchor.toml
├── Cargo.toml
├── app
├── migrations
│ └── deploy.ts
├── package.json
├── programs
│ └── vote_program
│ ├── Cargo.toml
│ ├── Xargo.toml
│ └── src
│ └── lib.rs <--------- Output Rust file
├── target
│ └── deploy
│ └── vote_program-keypair.json
├── tests
│ └── vote_program.ts
├── ts-programs
│ ├── package.json
│ └── src
│ └── voteProgram.ts <--------- Input Typescript file
├── tsconfig.json
└── yarn.lock
If you’re in the root directory of the program, use the following command:
poseidon build
And if you're not in the root directory or just want to compile by specifying the location, use the following command:
poseidon compile -i ts-programs/src/voteProgram.ts -o programs/vote-program/src/lib.rs
Once the code is transpiled to lib.rs
anchor build
Let’s replace the contents of tests/vote-program.ts
with the code below:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { VoteProgram } from "../target/types/vote_program";
import { assert } from "chai";
describe("vote program", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.VoteProgram as Program<VoteProgram>;
const voteState = anchor.web3.PublicKey.findProgramAddressSync(
[anchor.utils.bytes.utf8.encode("vote")],
program.programId
)[0];
it("Create and initialize vote state", async () => {
const txid = await program.methods
.initialize()
.accounts({
user: provider.wallet.publicKey,
})
.rpc();
console.log("Initialize tx:", txid);
const voteStateAccount = await program.account.voteState.fetch(voteState);
assert.ok(voteStateAccount.vote.eq(new anchor.BN(0)));
});
it("Upvote", async () => {
const txid = await program.methods.upvote().accounts({}).rpc();
console.log("upvote tx:", txid);
const voteStateAccount = await program.account.voteState.fetch(voteState);
assert.ok(voteStateAccount.vote.eq(new anchor.BN(1)));
});
it("Downvote", async () => {
const txid = await program.methods.downvote().accounts({}).rpc();
console.log("downvote tx:", txid);
const voteStateAccount = await program.account.voteState.fetch(voteState);
assert.ok(voteStateAccount.vote.eq(new anchor.BN(0)));
});
});
For testing it locally, we can run
poseidon test
This command will build the program, start a local validator with the program deployed, and run all the tests in the tests
folder. This is a quick way to check if your program works correctly. Ideally, you should see all your tests pass like this:
vote program
Initialize tx: 4uNEPU1dTXnNDgs3thgbkqQhN11xscbgcV1362Wv2nXRJSCfsra6B1AP24y6qjCXGLWrqjrrzFrtCf7S1YF6tRkZ
✔ Create and initialize vote state (426ms)
upvote tx: 2j7FypJmk5yyiugYVxPcgmWQkG7YYCUXdBEpzACJAv2UPXQj6b3tS47S3pN1dTr8JsCt3czYDMo62DuxjUjLNe78
✔ Upvote (471ms)
downvote tx: pTKwbkU9NTFdLaRFRTZCwuYaAHrYX44dkLAHau7GsBWvaEjsV5U6gYX59Ku6DKrXENsyQd5cirtSwBtBC9zN9Ut
✔ Downvote (466ms)
3 passing (1s)
If you want to verify it on the Solana Devnet (a network for developers testing their programs), use this command:
anchor test --provider.cluster devnet
After all the tests have passed, you can copy the transaction IDs and verify them on Solana’s blockchain explorer.
Here’s the example of the transaction ID (ApCnLHqiAm...amxDb439jg) might look like in the explorer on Devnet.
Thoughts & Takeaway
Congratulations! 🎉 You've completed your first Solana program in TypeScript!
Poseidon helps by transpiling your TypeScript program into Rust using the Anchor framework format. You can check out examples/vote/rust/vote.rs to see what the code looks like in Rust. This will help you better understand Rust syntax and Solana’s design principles.
After finishing this tutorial, we highly recommend going through all the resources in the reference section one-by-one. This will give you a more comprehensive understanding of how Solana works and help clarify some common jargon, such as account, PDA, rent, and more.
We hope you enjoyed this tutorial, and we look forward to seeing you in the wild but exciting Solana space!
Reference
- https://solana.com/docs/core/accounts
- https://docs.solanalabs.com/implemented-proposals/rent
- https://solana.com/docs/core/pda
Mapping into Anchor
In this section, we will dive deep into how TypeScript code is mapped to the Anchor framework in Rust. We will use the escrow
program as the example to illustrate the differences and similarities between the two.
Comparison Table
Solana Term | TypeScript (Poseidon) | Rust (Anchor) |
---|---|---|
Program | Class | Module |
Instruction | Method | Function |
Account (State) | Interface | Struct |
Escrow
Program
You can find the code for the escrow
program in the examples/escrow
directory of the Poseidon repository.
TypeScript (Poseidon)
Let's start with the TypeScript code for the escrow
program. This code defines the structure and logic of the program using Poseidon.
import {
Account,
AssociatedTokenAccount,
Mint,
Pubkey,
Seeds,
Signer,
SystemAccount,
TokenAccount,
TokenProgram,
UncheckedAccount,
u64,
u8,
} from "@solanaturbine/poseidon";
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make(
maker: Signer,
makerMint: Mint,
takerMint: Mint,
makerAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState,
depositAmount: u64,
offerAmount: u64,
seed: u64
) {
makerAta.derive(makerMint, maker.key);
auth.derive(["auth"]);
vault.derive(["vault", escrow.key], makerMint, auth.key).init();
escrow.derive(["escrow", maker.key, seed.toBytes()]).init();
escrow.authBump = auth.getBump();
escrow.vaultBump = vault.getBump();
escrow.escrowBump = escrow.getBump();
escrow.maker = maker.key;
escrow.makerMint = makerMint.key;
escrow.takerMint = takerMint.key;
escrow.amount = offerAmount;
escrow.seed = seed;
TokenProgram.transfer(
makerAta, // from
vault, // to
maker, // authority
depositAmount // amount to transferred
);
}
refund(
maker: Signer,
makerMint: Mint,
makerAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState
) {
makerAta.derive(makerMint, maker.key);
auth.derive(["auth"]);
vault.derive(["vault", escrow.key], makerMint, auth.key);
escrow
.derive(["escrow", maker.key, escrow.seed.toBytes()])
.has([maker])
.close(maker);
TokenProgram.transfer(
vault,
makerAta,
auth,
vault.amount,
["auth", escrow.authBump.toBytes()] // Seeds for the PDA signing
);
}
take(
taker: Signer,
maker: SystemAccount,
takerMint: Mint,
makerMint: Mint,
takerAta: AssociatedTokenAccount,
takerReceiveAta: AssociatedTokenAccount,
makerReceiveAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState
) {
takerAta.derive(takerMint, taker.key);
takerReceiveAta.derive(makerMint, taker.key).initIfNeeded();
makerReceiveAta.derive(takerMint, maker.key).initIfNeeded();
auth.derive(["auth"]);
vault.derive(["vault", escrow.key], makerMint, auth.key);
escrow
.derive(["escrow", maker.key, escrow.seed.toBytes()])
.has([maker, makerMint, takerMint])
.close(maker);
TokenProgram.transfer(takerAta, makerReceiveAta, taker, escrow.amount);
// Explicitly define the seeds for the PDA signing
let seeds: Seeds = ["auth", escrow.authBump.toBytes()];
TokenProgram.transfer(vault, takerReceiveAta, auth, vault.amount, seeds);
}
}
export interface EscrowState extends Account {
maker: Pubkey;
makerMint: Pubkey;
takerMint: Pubkey;
amount: u64;
seed: u64;
authBump: u8;
escrowBump: u8;
vaultBump: u8;
}
Rust (Anchor)
Now, let's look at the equivalent Rust code using the Anchor framework.
use anchor_lang::prelude::*;
use anchor_spl::{
token::{
TokenAccount, Mint, Token, transfer as transfer_spl, Transfer as TransferSPL,
},
associated_token::AssociatedToken,
};
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
pub fn make(
ctx: Context<MakeContext>,
deposit_amount: u64,
offer_amount: u64,
seed: u64,
) -> Result<()> {
ctx.accounts.escrow.auth_bump = ctx.bumps.auth;
ctx.accounts.escrow.vault_bump = ctx.bumps.vault;
ctx.accounts.escrow.escrow_bump = ctx.bumps.escrow;
ctx.accounts.escrow.maker = ctx.accounts.maker.key();
ctx.accounts.escrow.maker_mint = ctx.accounts.maker_mint.key();
ctx.accounts.escrow.taker_mint = ctx.accounts.taker_mint.key();
ctx.accounts.escrow.amount = offer_amount;
ctx.accounts.escrow.seed = seed;
let cpi_accounts = TransferSPL {
from: ctx.accounts.maker_ata.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.maker.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer_spl(cpi_ctx, deposit_amount)?;
Ok(())
}
pub fn refund(ctx: Context<RefundContext>) -> Result<()> {
let cpi_accounts = TransferSPL {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.maker_ata.to_account_info(),
authority: ctx.accounts.auth.to_account_info(),
};
let signer_seeds = &[&b"auth"[..], &[ctx.accounts.escrow.auth_bump]];
let binding = [&signer_seeds[..]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
&binding,
);
transfer_spl(cpi_ctx, ctx.accounts.vault.amount)?;
Ok(())
}
pub fn take(ctx: Context<TakeContext>) -> Result<()> {
let cpi_accounts = TransferSPL {
from: ctx.accounts.taker_ata.to_account_info(),
to: ctx.accounts.maker_receive_ata.to_account_info(),
authority: ctx.accounts.taker.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer_spl(cpi_ctx, ctx.accounts.escrow.amount)?;
let cpi_accounts = TransferSPL {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.taker_receive_ata.to_account_info(),
authority: ctx.accounts.auth.to_account_info(),
};
let signer_seeds = &[&b"auth"[..], &[ctx.accounts.escrow.auth_bump]];
let binding = [&signer_seeds[..]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
&binding,
);
transfer_spl(cpi_ctx, ctx.accounts.vault.amount)?;
Ok(())
}
}
#[derive(Accounts)]
#[instruction(seed:u64)]
pub struct MakeContext<'info> {
#[account(
mut,
associated_token::mint = maker_mint,
associated_token::authority = maker,
)]
pub maker_ata: Account<'info, TokenAccount>,
#[account(seeds = [b"auth"], bump)]
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
#[account(mut)]
pub maker: Signer<'info>,
#[account()]
pub maker_mint: Account<'info, Mint>,
#[account(
init,
payer = maker,
seeds = [b"vault",
escrow.key().as_ref()],
token::mint = maker_mint,
token::authority = auth,
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account()]
pub taker_mint: Account<'info, Mint>,
#[account(
init,
payer = maker,
space = 123,
seeds = [b"escrow",
maker.key().as_ref(),
seed.to_le_bytes().as_ref()],
bump,
)]
pub escrow: Account<'info, EscrowState>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct RefundContext<'info> {
#[account(
mut,
associated_token::mint = maker_mint,
associated_token::authority = maker,
)]
pub maker_ata: Account<'info, TokenAccount>,
#[account(
mut,
seeds = [b"vault",
escrow.key().as_ref()],
token::mint = maker_mint,
token::authority = auth,
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account()]
pub maker_mint: Account<'info, Mint>,
#[account(mut)]
pub maker: Signer<'info>,
#[account(
mut,
seeds = [b"escrow",
maker.key().as_ref(),
escrow.seed.to_le_bytes().as_ref()],
has_one = maker,
bump,
close = maker,
)]
pub escrow: Account<'info, EscrowState>,
#[account(seeds = [b"auth"], bump)]
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct TakeContext<'info> {
#[account(
init_if_needed,
payer = taker,
associated_token::mint = maker_mint,
associated_token::authority = taker,
)]
pub taker_receive_ata: Account<'info, TokenAccount>,
#[account(mut)]
pub taker: Signer<'info>,
#[account(
mut,
seeds = [b"vault",
escrow.key().as_ref()],
token::mint = maker_mint,
token::authority = auth,
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(
mut,
associated_token::mint = taker_mint,
associated_token::authority = taker,
)]
pub taker_ata: Account<'info, TokenAccount>,
#[account(seeds = [b"auth"], bump)]
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
#[account(mut)]
pub maker: SystemAccount<'info>,
#[account(
mut,
seeds = [b"escrow",
maker.key().as_ref(),
escrow.seed.to_le_bytes().as_ref()],
has_one = maker,
has_one = maker_mint,
has_one = taker_mint,
bump,
close = maker,
)]
pub escrow: Account<'info, EscrowState>,
#[account()]
pub taker_mint: Account<'info, Mint>,
#[account()]
pub maker_mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = taker,
associated_token::mint = taker_mint,
associated_token::authority = maker,
)]
pub maker_receive_ata: Account<'info, TokenAccount>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct EscrowState {
pub maker: Pubkey,
pub maker_mint: Pubkey,
pub taker_mint: Pubkey,
pub amount: u64,
pub seed: u64,
pub auth_bump: u8,
pub escrow_bump: u8,
pub vault_bump: u8,
}
Program
In TypeScript, the program is defined as a class with a static PROGRAM_ID
to specify the program ID.
import { Pubkey } from "@solanaturbine/poseidon";
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
}
And poseidon
will transpile it into the following Rust code.
use anchor_lang::prelude::*;
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
}
Notice that Anchor will generate the program ID for you.
Get your program IDs with this command inside your Anchor project.
$ anchor keys list
# Output
# <program_name>: <program_id>
Instruction
To define instructions in TypeScript, you would typically define methods inside the program class.
import { Pubkey } from "@solanaturbine/poseidon";
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make() {}
refund() {}
take() {}
}
And the context for each instruction is implicit in the method parameters.
import {
Account,
AssociatedTokenAccount,
Mint,
Pubkey,
Seeds,
Signer,
SystemAccount,
TokenAccount,
TokenProgram,
UncheckedAccount,
u64,
u8,
} from "@solanaturbine/poseidon";
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make(
maker: Signer,
makerMint: Mint,
takerMint: Mint,
makerAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState, // custom state account, will explain in the next section
depositAmount: u64,
offerAmount: u64,
seed: u64
) {}
refund(
maker: Signer,
makerMint: Mint,
makerAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState // custom state account, will explain in the next section
) {}
take(
taker: Signer,
maker: SystemAccount,
takerMint: Mint,
makerMint: Mint,
takerAta: AssociatedTokenAccount,
takerReceiveAta: AssociatedTokenAccount,
makerReceiveAta: AssociatedTokenAccount,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState // custom state account, will explain in the next section
) {}
}
@solanaturbine/poseidon
package provides the necessary types for defining instructions in TypeScript, such as Rust types (u8
, u64
, i8
, i128
, boolean
, string
), SPL types (Pubkey
, AssociatedTokenAccount
, Mint
, TokenAccount
, TokenProgram
), Anchor account types (Signer
, UncheckedAccount
, SystemAccount
), etc.
It will transpile the TypeScript code into the following Rust code.
use anchor_lang::prelude::*;
use anchor_spl::{
token::{TokenAccount, Mint, Token},
associated_token::AssociatedToken,
};
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
pub fn make(
ctx: Context<MakeContext>,
deposit_amount: u64,
offer_amount: u64,
seed: u64,
) -> Result<()> {
Ok(())
}
pub fn refund(ctx: Context<RefundContext>) -> Result<()> {
Ok(())
}
pub fn take(ctx: Context<TakeContext>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct MakeContext<'info> {
pub escrow: Account<'info, EscrowState>,
pub taker_mint: Account<'info, Mint>,
#[account(mut)]
pub maker: Signer<'info>,
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
pub maker_ata: Account<'info, TokenAccount>,
pub vault: Account<'info, TokenAccount>,
pub maker_mint: Account<'info, Mint>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct RefundContext<'info> {
pub escrow: Account<'info, EscrowState>,
pub maker_ata: Account<'info, TokenAccount>,
#[account(mut)]
pub maker: Signer<'info>,
pub maker_mint: Account<'info, Mint>,
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
pub vault: Account<'info, TokenAccount>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct TakeContext<'info> {
#[account(mut)]
pub maker: SystemAccount<'info>,
pub taker_ata: Account<'info, TokenAccount>,
pub vault: Account<'info, TokenAccount>,
pub escrow: Account<'info, EscrowState>,
#[account(mut)]
pub taker: Signer<'info>,
pub maker_mint: Account<'info, Mint>,
pub taker_mint: Account<'info, Mint>,
pub taker_receive_ata: Account<'info, TokenAccount>,
pub maker_receive_ata: Account<'info, TokenAccount>,
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
You might notice that the accounts defined in TypeScript are automatically transpiled into the Rust account struct, which is how the instruction context is typically organized.
If you have additional parameters that are not accounts, you can pass them as arguments after the accounts. Like make
instruction, it has depositAmount
, offerAmount
, and seed
as additional parameters.
State
State is the data stored on Solana accounts.
Notice that the term state is used to describe the data stored on Solana accounts, while the term account is used to describe the Solana account itself.
Define Custom State Accounts
In TypeScript, custom state accounts are defined as an Interface
that extends Account
.
import { Account, Pubkey, u64, u8 } from "@solanaturbine/poseidon";
export interface EscrowState extends Account {
maker: Pubkey;
makerMint: Pubkey;
takerMint: Pubkey;
amount: u64;
seed: u64;
authBump: u8;
escrowBump: u8;
vaultBump: u8;
}
You can use types from the @solanaturbine/poseidon
package to define the fields of the custom state account.
After transpiling, the custom state account will be defined as a struct
in Rust.
#[account]
pub struct EscrowState {
pub maker: Pubkey,
pub maker_mint: Pubkey,
pub taker_mint: Pubkey,
pub amount: u64,
pub seed: u64,
pub auth_bump: u8,
pub escrow_bump: u8,
pub vault_bump: u8,
}
State Manipulation
To set the state of an account, you can simply assign the values to the fields of the account.
// ...
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make(
maker: Signer,
escrow: EscrowState,
makerAta: AssociatedTokenAccount,
makerMint: Mint,
takerMint: Mint,
auth: UncheckedAccount,
vault: TokenAccount,
depositAmount: u64,
offerAmount: u64,
seed: u64
) {
escrow.maker = maker.key;
escrow.makerMint = makerMint.key;
escrow.takerMint = takerMint.key;
escrow.amount = offerAmount;
escrow.seed = seed;
}
}
The corresponding Rust code will be generated as follows.
// ...
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
pub fn make(
ctx: Context<MakeContext>,
deposit_amount: u64,
offer_amount: u64,
seed: u64,
) -> Result<()> {
ctx.accounts.escrow.maker = ctx.accounts.maker.key();
ctx.accounts.escrow.maker_mint = ctx.accounts.maker_mint.key();
ctx.accounts.escrow.taker_mint = ctx.accounts.taker_mint.key();
ctx.accounts.escrow.amount = offer_amount;
ctx.accounts.escrow.seed = seed;
}
}
Also if you want to do some arithmetic operations, @solanaturbine/poseidon
package provides the necessary types for that.
Check out vote example to see how to use them. Here's a snippet from the example:
// initialize the state
state.vote = new i64(0);
// increment the state
state.vote = state.vote.add(1);
// decrement the state
state.vote = state.vote.sub(1);
Poseidon Type Reference
Type | Anchor | Poseidon |
---|---|---|
Boolean | bool | Boolean |
Integer | u8/u16/u32/i8/i16/i32 | u8/u16/u32/i8/i16/i32 |
String | String | String<N> |
Vector | Vec<T> | Vec<T, N> |
where N
is the max length of the type.
Program Derived Address (PDA)
Program Derived Addresses (PDAs) are a way to derive a new address from a seed
and a program id
. This is useful for creating new accounts that are tied to a specific program.
For example, in the escrow program, the escrow
account is created as a PDA. This ensures that the escrow
account is tied to the escrow program and cannot be controlled by any other program or entity.
To define an account as a PDA with @solanaturbine/poseidon
, you can use the derive
method for every account by specifying your seed
within an array ([]
) as the first parameter.
seed
can be a string, a number, a Pubkey, or even the combination of them.
// ...
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make() {
// Wrap the seed with an array
auth.derive(["auth"]); // seed: string("auth")
vault.derive(["vault", escrow.key]); // seed: string("vault") + Pubkey(escrow.key)
escrow.derive(["escrow", maker.key, seed.toBytes()]); // seed: string("escrow") + Pubkey(maker.key) + number(seed.toBytes())
escrow.authBump = auth.getBump();
escrow.vaultBump = vault.getBump();
escrow.escrowBump = escrow.getBump();
}
}
The magic behind PDA is that it uses the program id
as the base address and the seed
(as we created above) with a bump
(a number between 0 to 255) as the offset to derive a new address, which is unique and off the Ed25519 curve, without a corresponding private key. This technique guarantees that the derived address is only controllable by the program that created it.
Normally, we'll store the bump
value in the state account to ensure that the program can always derive the same address and save the cost of bump calculation during the runtime. You can use the getBump
method to get the bump value for the account.
The corresponding Rust code will be generated as follows.
// ...
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
pub fn make(
ctx: Context<MakeContext>,
) -> Result<()> {
ctx.accounts.escrow.auth_bump = ctx.bumps.auth;
ctx.accounts.escrow.vault_bump = ctx.bumps.vault;
ctx.accounts.escrow.escrow_bump = ctx.bumps.escrow;
Ok(())
}
}
#[derive(Accounts)]
#[instruction(seed:u64)]
pub struct MakeContext<'info> {
#[account(seeds = [b"auth"], bump)]
/// CHECK: This acc is safe
pub auth: UncheckedAccount<'info>,
#[account(
seeds = [b"vault", escrow.key().as_ref()],
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(
seeds = [b"escrow", maker.key().as_ref(), seed.to_le_bytes().as_ref()],
bump,
)]
pub escrow: Account<'info, EscrowState>,
}
If you're creating a PDA with a given bump
, you can use the deriveWithBump
method with the bump
following the seed
instead. See the example below or the vault example for more details:
auth.deriveWithBump(["auth", state.key], state.authBump);
We highly recommend you to go through the official documentation to understand the concept of PDAs in Solana.
Account Constraints
We have seen an example of how to define account constraints in previous sections while we're creating PDAs, e.g. #[account(seeds = [b"auth"], bump)]
. In this section, we will discuss the constraints in more detail.
Anchor provides a way to define constraints on accounts that are passed to the program by using the #[account(..)]
attribute. These constraints are used to ensure that the account passed to the program is the correct account. This is done by checking the account's address and the account's data.
Here are some commonly used constraints if you want to define them in TypeScript:
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
make(
escrow: EscrowState,
makerMint: Mint,
auth: UncheckedAccount,
vault: TokenAccount
) {
// `init` constraint: create a new account
vault.derive(["vault", escrow.key], makerMint, auth.key).init();
}
refund(maker: Signer, escrow: EscrowState) {
escrow
.derive(["escrow", maker.key, escrow.seed.toBytes()])
.has([maker]) // `has_one` constraint: check if the data stored inside the `escrow.maker` is the same as the `maker` account
.close(maker); // `close` constraint: close the account after the instruction is executed, transfer the remaining SOL to the `maker` account
}
take(
taker: Signer,
maker: SystemAccount,
takerAta: AssociatedTokenAccount,
makerMint: Mint,
takerMint: Mint,
escrow: EscrowState
) {
takerAta
.derive(makerMint, taker.key) // SPL constraints: check if the `taker` account has the same mint as the `makerMint` account and the authority is the `taker` account
.initIfNeeded(); // `init_if_needed` constraint: initialize the account if it doesn't exist
escrow
.derive(["escrow", maker.key, escrow.seed.toBytes()])
.has([maker, makerMint, takerMint]) // `has_one` constraint: can specify multiple accounts to check
.close(maker);
}
}
export interface EscrowState extends Account {
maker: Pubkey;
makerMint: Pubkey;
takerMint: Pubkey;
amount: u64;
seed: u64;
authBump: u8;
escrowBump: u8;
vaultBump: u8;
}
You can simply define the constraints by chaining the constraints methods after the account you want to check and make sure the .derive()
method is called before the other constraints methods.
Normal Constraints
init
(and space
)
.init()
method is used to create a new account. It is used to create a new account with the given data. Poseidon will automatically calculate the space required for the account based on the how you define the account in the state interface and specify the space in Rust with space
constraint.
initIfNeeded
Exact same functionality as the init constraint but only runs if the account does not exist yet1.
If you're using .initIfNeeded()
method, you should add additional feature flags inside your Cargo.toml
file under your program's directory:
[features]
anchor-lang = { version = "xxx", features = ["init-if-needed"]}
seed
(and bump
)
This is the constraint we use to define PDAs.
The seed
constraint is used to derive a new address from the base address. The bump
value is a number between 0 to 255 that is used to derive a new address.
Use the .derive([seed])
method to derive a new address and use the .getBump()
method to get the bump value for the account.
Use the .deriveWithBump([seed], bump)
method to derive a new address with a bump value if you're creating a PDA with a bump.
The seed
and bump
constraints are required to use together to derive a new address.
close
.close(rentReceiver)
method is used to close the account after the instruction is executed. It will transfer the remaining SOL to the account(rentReceiver
) passed to the method.
has
(or has_one
in Anchor)
.has([])
in TypeScript (or has_one
constraint in Anchor) is used to check if the data stored inside the account is the same as the data passed to the method. Like in the refund
method, we're checking if the maker
account's Pubkey is the same as the one stored inside escrow.maker
.
And has
constraint allows you to check multiple accounts at once. Like in the take
method, you can check if the maker
, makerMint
, and takerMint
accounts are the same as the ones stored inside the escrow
account.
SPL Constraints
mint
and authority
If the account is a TokenAccount
, .derive([seed], mint, authority)
method is used to check if the account has the same mint as the mint
account and the authority is the authority
account.
You can use it with the init
constraint to derive and initialize a new TokenAccount
, like vault
account in the make
method.
vault.derive(["vault", escrow.key], makerMint, auth.key).init();
For accounts of type AssociatedTokenAccount
, .derive(mint, authority)
is used instead.
vault.derive(makerMint, auth.key).initIfNeeded();
Check the Anchor documentation for more information on constraints.
Cross Program Invocation (CPI)
When a program invokes another program, it is called a cross program invocation (CPI). This is a powerful feature of Solana that allows programs to interact with each other. This is useful when you want to separate the logic of your program into multiple programs, or when you want to reuse the logic of another program.
@solanaturbine/poseidon
provides a few commonly used program like TokenProgram
and SystemProgram
for you to invoke the corresponding instructions.
Invoking Token Program
To transfer tokens inside your program, you can use the transfer
method from the TokenProgram
. Here's an example of how to transfer tokens from token accounts which controlled by different types of owner, one is a user's (associated) token account and the other is a PDA's token account:
// ...
export default class EscrowProgram {
static PROGRAM_ID = new Pubkey("11111111111111111111111111111111");
take(
taker: Signer,
maker: SystemAccount,
makerReceiveAta: AssociatedTokenAccount,
takerAta: AssociatedTokenAccount,
takerReceiveAta: AssociatedTokenAccount,
makerMint: Mint,
takerMint: Mint,
auth: UncheckedAccount,
vault: TokenAccount,
escrow: EscrowState
) {
makerReceiveAta.derive(takerMint, maker.key).initIfNeeded(); // Check if the associated token account is initialized
takerAta.derive(takerMint, taker.key); // Don't need to check if the ATA is initialized, because if it's not, the transfer will fail
takerReceiveAta.derive(makerMint, taker.key).initIfNeeded(); // Check if the associated token account is initialized
auth.derive(["auth"]);
vault.derive(["vault", escrow.key], makerMint, auth.key);
escrow
.derive(["escrow", maker.key, escrow.seed.toBytes()])
.has([maker, makerMint, takerMint]) // Check if the expected accounts are the same as the provided accounts
.close(maker);
// Cross program invocation
// Transfer tokens from taker's ATA to maker's ATA
TokenProgram.transfer(
takerAta, // from
makerReceiveAta, // to
taker, // authority
escrow.amount // amount to be sent
);
// Cross program invocation
// Transfer tokens from `vault` account to taker's ATA
// Seeds are used for signing the transaction since the `vault` account is owned by the `auth` PDA under the escrow program
let seeds: Seeds = ["auth", escrow.authBump.toBytes()];
TokenProgram.transfer(
vault, // from
takerReceiveAta, // to
auth, // authority
vault.amount, // amount to be sent
seeds // seeds will be at the last arguments if needed
);
}
}
export interface EscrowState extends Account {
maker: Pubkey;
makerMint: Pubkey;
takerMint: Pubkey;
amount: u64;
seed: u64;
authBump: u8;
escrowBump: u8;
vaultBump: u8;
}
In the example above, we transfer tokens from the taker's ATA to the maker's ATA and from the vault to the taker's ATA. We use the TokenProgram
to transfer the tokens.
Here's the corresponding Rust code for the transfer
CPI:
declare_id!("11111111111111111111111111111111");
#[program]
pub mod escrow_program {
use super::*;
pub fn make(
ctx: Context<MakeContext>,
deposit_amount: u64,
offer_amount: u64,
seed: u64,
) -> Result<()> {
let cpi_accounts = TransferSPL {
from: ctx.accounts.maker_ata.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.maker.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer_spl(cpi_ctx, deposit_amount)?;
Ok(())
}
pub fn refund(ctx: Context<RefundContext>) -> Result<()> {
let cpi_accounts = TransferSPL {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.maker_ata.to_account_info(),
authority: ctx.accounts.auth.to_account_info(),
};
let signer_seeds = &[&b"auth"[..], &[ctx.accounts.escrow.auth_bump]];
let binding = [&signer_seeds[..]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
&binding,
);
transfer_spl(cpi_ctx, ctx.accounts.vault.amount)?;
Ok(())
}
}
Invoking System Program
It's quite similar to how you invoke the TokenProgram
. Here's an example of how to invoke transfer
instruction in SystemProgram
:
// Invoke by normal account
SystemProgram.transfer(
owner, // from
vault, // to
amount // amount to be sent
);
// Invoke by PDA
SystemProgram.transfer(
vault, // from
owner, // to
amount, // amount to be sent
["vault", state.key, state.authBump] // seeds will be at the last arguments if needed
);
Can check the full codebase in the vault example.
Other Resources
- Solana Docs: https://docs.solana.com/
- Anchor Framework: https://www.anchor-lang.com/