Ngoprek Program Solana: Nyobain Anchor Sambil Bikin Vault
Bagaimana sebuah Program di Solana bisa dengan aman menyimpan dan mengelola aset digital? Dalam artikel ini, kita akan belajar membuat sebuah program vault sederhana menggunakan Anchor Framework. Framework ini sangat membantu kita untuk membuat program menjadi lebih mudah.
Konsep Dasar Vault di Solana
Secara simpel, vault itu seperti brankas digital di blockchain. Program (atau smart contract) ini berguna untuk menyimpan aset kripto (seperti SOL atau SPL Token) dengan aman. Tidak cuma menyimpan, vault juga bisa punya beberapa aturan sendiri. Misalnya, siapa saja yang boleh masukin aset, siapa yang boleh narik, dan kapan aset itu boleh ditarik.
Dalam konteks Solana, sebuah vault pada dasarnya adalah sebuah Program Derived Address (PDA) yang "megang" atau jadi authority atas token account lain. Jadi, tokennya gak disimpan dalam programnya langsung, tapi di sebuah token account yang dikontrol sama di program vault lewat PDA.
Sebelum implementasi kita akan membahas sedikit tentang Anchor Framework. Karena framework ini yang nantinya akan sering kita gunakan untuk membuat program di Solana.
Pengantar Anchor
Kenapa Anchor? Soalnya framework ini membuat hidup kita sebagai developer menjadi jauh lebih mudah, karena sudah menyediakan banyak boilerplate secara default. Untuk instalasi, kamu bisa langsung menuju ke dokumentasi resmi Anchor di ini.
Komponen Utama
Secara konseptual, program Anchor dibangun di atas tiga komponen utama yang saling bekerja sama, yaitu:
1. state (Data)
State ini digunakan untuk mendefinisikan struktur data apa yang akan kita simpan di blockchain. Jika program kita adalah database, maka state adalah definisi dari tabel-tabel kita.
2. context (Aturan)
Komponen ini mendefinisikan aturan, validasi, dan semua akun yang diperlukan untuk menjalankan sebuah fungsi. Ini adalah lapisan keamanan dan logika bisnis. Untuk setiap fungsi di lib.rs (misalnya withdraw), akan ada context yang sesuai. context ini akan memastikan, "Apakah pengguna ini merupakan pemilik brankas ini? Apakah saldo-nya ada?".
3. lib.rs (API Publik)
Peran komponen ini yaitu menjadi penghubung antara pengguna dan logika program. Ini adalah kumpulan endpoint. Ketika pengguna memanggil fungsi withdraw, lib.rs akan meneruskannya ke Context<Withdraw> yang kemudian akan menjalankan semua validasi dan logika yang telah didefinisikan.
Struktur Project
Buka terminal dan jalanin perintah anchor init <nama_project> untuk membuat project baru.
Nantinya perintah itu akan me-generate project baru dan menghasilkan beberapa file dan folder, yaitu:
- programs/: Di sinilah folder utama tempat menulis kode Program kita.
- tests/: Buat nulis kode testing menggunakan TypeScript/JavaScript.
- migrations/: Kode untuk proses deployment ditulis.
- Anchor.toml: File konfigurasi utama project kita. Di sini kita bisa menentukan versi Solana, address program, dan lainnya.
Nantinya kita hanya menggunakan programs/<nama_project>/src/lib.rs untuk menulis kode utama Program kita.
Konsep Macro di Anchor
Anchor memiliki beberapa beberapa "macro" (kode program yang ditandai dengan #[...]) yang tersedia. Macro ini merupakan metaprogramming untuk generate kode ketika proses compile.
#[program]
Ini adalah pintu gerbang utama programmu. Semua fungsi publik (instruksi) yang bisa dipanggil dari luar didefinisikan di sini.
#[program]
pub mod my_program {
use super::*;
// Setiap fungsi di sini adalah sebuah instruksi
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// ...
Ok(())
}
pub fn do_something(ctx: Context<DoSomething>, some_data: u64) -> Result<()> {
// ...
Ok(())
}
} Setiap fungsi di dalam blok ini akan menjadi instruksi yang bisa dipanggil oleh client.
#[derive(Accounts)]
Ini adalah Blueprint untuk setiap instruksi. Setiap struct yang menggunakan macro ini mendefinisikan semua account yang dibutuhkan dan aturan validasinya.
- init: Memberitahu Anchor untuk membuat account baru.
- mut: Memberitahu bahwa account ini bisa diubah (mutable).
- seeds: Digunakan untuk memvalidasi atau membuat alamat PDA (Program Derived Address).
- has_one: Constraint untuk memvalidasi hubungan antar account (misalnya, vault_state ini harus punya owner yang sama dengan user yang memanggil).
- Signer: Memastikan account tersebut telah menandatangani transaksi.
Context<T>
Ini merupakan wrapper atau bungkusan yang diberikan oleh Anchor pada setiap instruksi. Ia berisi semua account yang sudah divalidasi sesuai dengan Blueprint(T).
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// ctx.accounts berisi semua account dari Blueprint `Initialize`
let user_account = &ctx.accounts.user;
let vault_account = &mut ctx.accounts.vault; // bisa diubah karena `mut`
Ok(())
} #[account]
Macro ini digunakan untuk mendefinisikan struktur data yang akan disimpan di dalam sebuah account. Anchor secara otomatis akan mengurus proses serialize dan deserialize.
#[account]
pub struct VaultState {
pub owner: Pubkey,
pub amount: u64,
pub is_locked: bool,
pub bump: u8,
} Setiap account yang menyimpan data custom akan menggunakan struct dengan macro ini.
Alur Kerja Menggunakan Anchor
Secara singkat, alur kerja untuk membuat program dengan Anchor adalah sebagai berikut:
- Definisikan Data: Buat struct dengan #[account] untuk menentukan data apa saja yang ingin kamu simpan di blockchain.
- Buat Instruksi: Di dalam #[program], buat fungsi untuk setiap aksi yang bisa dilakukan (misalnya initialize, deposit, withdraw).
- Buat Blueprint untuk Setiap Instruksi: Untuk setiap fungsi di #[program], buat struct dengan #[derive(Accounts)] untuk mendefinisikan dan memvalidasi account yang dibutuhkan.
- Tulis Logika: Isi setiap fungsi instruksi dengan logika bisnis yang sesuai, kamu bisa berinteraksi dengan account melalui ctx.accounts.
- Tulis Unit Test (Sangat Direkomendasikan): Di folder tests/, buat kode untuk memanggil setiap instruksi dan memastikan program berjalan dengan benar dalam berbagai skenario.
- Deploy & Interaksi: Setelah lolos test, deploy programmu ke jaringan Solana mainnet dan buat antarmuka client untuk berinteraksi dengannya.
Implementasi Program Vault dengan Anchor
1. Generate Project
Jalankan perintah berikut untuk membuat project baru di Anchor:
anchor ini <project_name>.
2. Mendefinisikan State Vault
Program yang akan kita buat adalah versi simpel dari sebuah Vault, yaitu hanya untuk menyimpan saldo SOL (native coin di Solana). Karena kita hanya akan menyimpan saldo SOL, maka nantinya kita akan banyak berurusan dengan SystemProgram.
Vault ini punya dua komponen utama yang dikontrol program, yaitu:
- State Account: Ini seperti "otak" atau buku catatannya si vault. Isinya cuman informasi penting buat ngejalanin vault.
- Vault Account: Ini adalah "brankas"-nya. Sebuah system account biasa yang alamatnya diturunkan dari program (PDA) dan tugas cuman satu yaitu menyimpan SOL.
Berikut implementasinya:
#[account]
pub struct VaultState {
pub vault_bump: u8,
pub state_bump: u8,
}
impl Space for VaultState {
const INIT_SPACE: usize = 8 + 1 * 2;
} Penjelasan:
- vault_bump dan state_bump: Ini adalah bump seed yang didapat pas kita bikin PDA. Anggap saja ini kayak "nomor seri" atau bagian dari kombinasi kunci yang harus disimpan biar nanti kita bisa nemuin lagi alamat PDA yang sama dan mengontrolnya. Kita simpan bump buat state account dan vault account.
- INIT_SPACE: Ini buat ngasih tau Solana berapa banyak ruang penyimpanan yang perlu disiapin. 8 itu wajib dari Anchor (disebut discriminator), lalu 1 * 2 karena kita punya dua field u8 (masing-masing 1 byte).
VaultState ini nggak menyimpan SOL sama sekali. Dia cuman menyimpan "kunci" (bump) buat menemukan brankasnya (vault) nanti.
3. Instruksi Initialize
Instruksi ini nantinya dipanggil sekali untuk membuat satu set vault untuk seorang user.
Blueprint
Blueprint ini mendefinisikan semua account yang dibutuhkan untuk membuat vault.
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub user: Signer<'info>, // User yang bayar & jadi owner
#[account(
init,
payer = user,
space = VaultState::INIT_SPACE,
seeds = [b"state", user.key().as_ref()],
bump
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", vault_state.key().as_ref()],
bump
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
} Penjelasan:
- user: Signer<'info>: Siapa pun yang memanggil instruksi ini harus tanda tangan transaksi dan dia yang bayar sewa (rent) untuk akun-akun yang akan dibuat.
- vault_state: Account<'info, VaultState>: Ini perintah untuk membuat PDA untuk VaultState.
- seeds = [b"state", user.key().as_ref()]: Ini "resep" atau seeds untuk membuat address PDA-nya. Alamatnya akan unik untuk setiap user. Jadi, satu user cuman bisa punya satu VaultState.
- vault: SystemAccount<'info>: Ini adalah PDA si brankas itu sendiri.
- seeds = [b"vault", vault_state.key().as_ref()]: Address brankas ini bergantung pada alamat vault_state. Seperti inilah cara kita ngaitin antara "otak" / VaultState dan "brankas"-nya.
Logika Instruksi
Setelah Anchor memvalidasi semua account di Blueprint, logika di dalam fungsi initialize akan berjalan.
impl<'info> Initialize<'info> {
pub fn initialize(&mut self, bumps: &InitializeBumps) -> Result<()> {
// Logika untuk membayar sewa (rent) vault PDA
let rent_exempt_lamports = Rent::get()?.minimum_balance(self.vault.to_account_info().data_len());
transfer(
CpiContext::new(
self.system_program.to_account_info(),
Transfer {
from: self.user.to_account_info(),
to: self.vault.to_account_info(),
},
),
rent_exempt_lamports,
)?;
// Menyimpan bump ke dalam state
self.vault_state.vault_bump = bumps.vault;
self.vault_state.state_bump = bumps.vault_state;
Ok(())
}
} Logika ini melakukan dua tugas penting:
- Membayar Sewa: Account di Solana butuh saldo minimal SOL agar tidak terhapus. Kode di atas mentransfer sejumlah SOL dari user ke vault PDA untuk membayar sewa ini.
- Menyimpan Kunci: Program menyimpan bump (nomor unik dari seeds PDA) yang didapat ke dalam vault_state. Tujuannya agar nanti di instruksi lain (deposit atau withdraw), kita bisa menggunakan bump ini untuk menemukan kembali alamat vault dan mengontrolnya.
4. Instruksi Deposit
Setelah vault berhasil dibuat, instruksi deposit digunakan untuk memasukkan atau menambah saldo SOL ke dalamnya.
Blueprint
Untuk deposit dan withdraw, kita bisa menggunakan satu Blueprint yang sama karena account yang dibutuhkan sangat mirip. Kita bisa menamakannya Payment.
#[derive(Accounts)]
pub struct Payment<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
seeds = [b"state", user.key().as_ref()],
bump = vault_state.state_bump
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"vault", vault_state.key().as_ref()],
bump = vault_state.vault_bump
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
} Penjelasan:
- user: Signer<'info>: Orang yang mengirimkan SOL. Ia harus menandatangani transaksi sebagai bukti persetujuan.
- vault_state: Account<'info, VaultState>: Account "otak" ini dibutuhkan untuk membaca bump yang sudah kita simpan. bump ini krusial menemukan alamat vault yang benar.
- vault: SystemAccount<'info>: Account "brankas" yang akan menerima dana. KIta tandai sebagai mut (mutable) karena saldonya akan bertambah.
Perhatikan, di sini kita tidak lagi menggunakan init karena account-nya sudah ada. Kita hanya perlu menemukan dan memvalidasinya menggunakan seeds dan bump.
Logika Instruksi
Logika untuk deposit sangat sederhana. Program hanya perlu menjadi perantara untuk mentransfer SOL dari user ke vault.
impl<'info> Payment<'info> {
pub fn deposit(&mut self, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: self.user.to_account_info(),
to: self.vault.to_account_info(),
};
let cpi_context = CpiContext::new(
self.system_program.to_account_info(),
cpi_accounts
);
transfer(cpi_context, amount)?;
Ok(())
}
} Penjelasan:
- Siapkan Transfer: Membuat konteks untuk CPI (Cross-Program Invocation) ke SystemProgram.
- Tentukan Tujuan: Transfer dilakukan dari user (from) ke vault (to).
- Eksekusi: Memanggil fungsi transfer dengan jumlah amount yang ditentukan oleh user saat memanggil instruksi ini.
Karena user adalah Signer, ia secara otomatis memberikan izin untuk mentransfer dana dari account-nya.
5. Instruksi Withdraw
Ini adalah instruksi untuk menarik kembali saldo SOL dari vault. Di sinilah "magic" dari Program Derived Address (PDA) benar-benar terlihat.
Blueprint
Untuk withdraw, kita akan menggunakan blueprint Payment yang sama persis dengan instruksi deposit. Ini karena account yang kita butuhkan sama, hanya arah transaksinya yang berbeda.
Logika Instruksi
Di sinilah bagaimana sebuah Program menandatangani transaksi menggunakan seeds dari PDA itu sendiri.
impl<'info> Payment<'info> {
pub fn withdraw(&mut self, amount: u64) -> Result<()> {
let cpi_program = self.system_program.to_account_info();
// 1. Arah transfer dibalik
let cpi_accounts = Transfer {
from: self.vault.to_account_info(),
to: self.user.to_account_info(),
};
// 2. Siapkan "seeds" untuk tanda tangan
let seeds = &[
b"vault",
self.vault_state.to_account_info().key.as_ref(),
&[self.vault_state.vault_bump],
];
let signer_seeds = &[&seeds[..]];
// 3. Panggil transfer dengan 'signer'
let cpi_ctx = CpiContext::new_with_signer(
cpi_program,
cpi_accounts,
signer_seeds
);
transfer(cpi_ctx, amount)?;
Ok(())
}
} Penjelasan:
- Arah Transfer Dibalik: Perhatikan bahwa from sekarang adalah self.vault dan to adalah self.user. Karena kita ingin mengirim SOL dari brankas ke user.
- Membuat signer_seeds: Ini digunakan untuk membuktikan bahwa program kita berhak memerintahkan vault PDA, kita harus menyediakan kembali kombinasi seeds dan bump asli yang digunakan untuk membuat address vault tersebut. Kombinasi b["vault", vault_state_key, vault_bump] ini adalah "kunci" rahasia yang hanya diketahui dan bisa direkonstruksi oleh program kita.
- Memanggil CpiContext::new_with_signer: Ini adalah cara kita memberi tahu Anchor, "Hei, jalankan CPI transfer ini, dan tolong tanda tangani atas nama vault PDA menggunakan signer_seeds yang diberikan."
Runtime Solana kemudian akan memverifikasi: "Apakah kombinasi seeds ini benar-benar menghasilkan address from (vault)? Jika ya, maka transaksi ini sah."
6. API Publik
Ini merupakan API Publik yang nantinya akan digunakan sebagai jalur oleh pengguna untuk mengakses program kita.
#[program]
pub mod vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.initialize(&ctx.bumps)
}
pub fn deposit(ctx: Context<Payment>, amount: u64) -> Result<()> {
ctx.accounts.deposit(amount)
}
pub fn withdraw(ctx: Context<Payment>, amount: u64) -> Result<()> {
ctx.accounts.withdraw(amount)
}
} Unit Testing
Test ini akan mensimulasikan semua langkah instruksi yang sudah kita buat, yaitu membuat vault (initialize), deposit, dan withdraw SOL.
Kita akan menggunakan jaringan devnet untuk proses Unit Testing ini.
1. Deploy to Testnet
Buka file Anchor di root project kamu dan pastikan konfigurasinya seperti ini:
...
[provider]
cluster = "devnet"
wallet = "/path/to/your/wallet/keypair.json"
... Penjelasan:
- cluster: Kita set network yang digunakan, pada case ini kita menggunakan devnet.
- wallet: Ini path ke wallet kamu dalam format byte. Pastikan wallet-nya memiliki saldo SOL di devnet. Kamu bisa ikutin tutorial di ini untuk generate wallet dan claim SOL di devnet.
Setelah itu, jalankan perintah ini di terminal project-mu untuk men-deploy ke devnet.
anchor deploy Jika berhasil, nantinya akan menampilkan program id seperti ini:
Program Id: ENazLJZqx6NvdxJCzcQpxSgy615DGv7MmQbcXEbUvgtx
Signature: 65h3quuASdveDCA7DcVTi4cmznM61UByJ5axfPFPJneEbSw4pZ5h4jnqHiywiFyHwqhdoYu9fruTkf7RFkDNvgti
Deploy success Program id adalah address program kita di network yang kita deploy.
Update Anchor.toml
Masukkan kode berikut untuk menentukan address program id yang sudah berhasil kamu deploy, nantinya address ini akan digunakan untuk unit testing.
...
[programs.devnet]
vault = "ENazLJZqx6NvdxJCzcQpxSgy615DGv7MmQbcXEbUvgtx"
... Ganti ENazLJZqx6NvdxJCzcQpxSgy615DGv7MmQbcXEbUvgtx menjadi address program id kamu.
2. Setup Unit Testing
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Vault as Program<Vault>;
const user = provider.wallet; Pada kode ini kita membuat koneksi ke devnet (sesuai pada Anchor.toml), kemudian membuat program kita, dan mendefinisikan user sebagai wallet yang akan kita gunakan.
3. Unit Testing Initialize
it("Is initialized!", async () => {
// Find PDA
[vaultStatePDA] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("state"), user.publicKey.toBuffer()],
program.programId,
);
[vaultPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault"), vaultStatePDA.toBuffer()],
program.programId,
);
// Call instruction
await program.methods
.initialize()
.accounts({
user: user.publicKey,
vaultState: vaultStatePDA,
vault: vaultPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// verification
const state = await program.account.vaultState.fetch(vaultStatePDA);
assert.ok(state.vaultBump, "Vault bump was not saved");
assert.ok(state.stateBump, "State bump was not saved");
}); Penjelasan:
- Menemukan PDA: PublicKey.findProgramAddressSync adalah fungsi di client untuk menemukan alamat PDA. Perhatikan seeds-nya (["state", user_key]) sama persis dengan yang di program Rust. Ini bisa digunakan untuk memprediksi alamat PDA bahkan sebelum dibuat.
- Memanggil Instruksi: .methods.initialize().accounts({...}).rpc adalah cara untuk memanggil instruksi. Kita menyedikan semua account yang dibutuhkan oleh Blueprint Initialize. Kemudian fungsi .rpc() akan mengirim transaksi.
- Verifikasi: Setelah instruksi berhasil, kita fetch data dari vaulStatePDA. Lalu kita gunakan assert.ok untuk memastikan bump sudah tersimpan dengan benar.
4. Unit Testing Deposit
it("Deposits SOL!", async () => {
const amount = new BN(0.1 * anchor.web3.LAMPORTS_PER_SOL); // 0.1 SOL
// get initial balance
const initialVaultBalance = await provider.connection.getBalance(vaultPDA);
// call instruction
await program.methods
.deposit(amount)
.accounts({
user: user.publicKey,
vaultState: vaultStatePDA,
vault: vaultPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// verification
const finalVaultBalance = await provider.connection.getBalance(vaultPDA);
assert.strictEqual(
finalVaultBalance,
initialVaultBalance + amount.toNumber(),
"Vault balance should have inscreased by the deposit amount"
);
}); Penjelasan:
- Define Amount: Kita perlu me-define amount dari jumlah SOL yang akan didepositkan menggunakan BN (BigNumber).
- Memanggil Instruksi: Kita memanggil .methods.deposit(amount) dan masukkan semua account yang dibutuhkan sesuai pada program yang dibuat pada blueprint Payment.
- Verifikasi: Pada tahap ini kita cek saldo vaultPDA setelah transaksi. assert.strictEqual memastikan saldo brankas bertambah tepat sejumlah amount yang sudah kita depositkan.
5. Unit Testing Withdraw
it("Withdraw SOL!", async () => {
const amount = new BN(0.1 * anchor.web3.LAMPORTS_PER_SOL); // 0.1 SOL
// get initial balance
const initialVaultBalance = await provider.connection.getBalance(vaultPDA);
// cell instruction
await program.methods
.withdraw(amount)
.accounts({
user: user.publicKey,
vaultState: vaultStatePDA,
vault: vaultPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// verification
const finalVaultBalance = await provider.connection.getBalance(vaultPDA);
assert.strictEqual(
finalVaultBalance,
initialVaultBalance - amount.toNumber(),
"Vault balance should have desreased by withdrawal amount",
);
}); Penjelasan"
- Define Amount: Sama seperti deposit, kita perlu define amount yang ingin kita tarik dari brankas.
- Memanggil Instruksi: Kita memanggil .methods.withdraw(amount) dan mengisi account sesuai dengan blueprint Payment.
- Verifikasi: Ini sama seperti deposit juga, yang membedakan cuman final balance harusnya initial balance - amount.
Full Code
Kamu bisa mengakses full kodenya di sini.
Key Takeaways (TL;DR)
- Blueprint (struct) wajib digunakan untuk keamanan.
- PDA adalah Kunci: PDA adalah account yang "dimiliki" oleh program. Program bisa memberi perintah atas nama PDA dengan cara "menandatangani" transaksi menggunakan seeds dan bump aslinya.
- Program ID itu Unit & Terikat ke Wallet: Program ini terkunci pada wallet yang digunakan untuk proses deploy (Upgrade Authority). Hanya wallet itu yang bisa memperbarui kode di program yang sudah di deploy.