Workshop 8: Xây dựng chương trình nâng cao
Bài viết này với đầy đủ các thông tin giúp bạn hiểu được cơ chế và cách sử dụng Cross Program Invocation. Hiểu các adhoc về computation limit, tx size etc… và nằm rõ hơn về các kĩ thuật debug program, work around limitations. Tham khảo ngay!

Bài viết này sẽ giúp bạn:
- Hiểu được cơ chế và cách sử dụng Cross Program Invocation
- Hiểu các adhoc về computation limit, tx size etc…
- Các kĩ thuật debug program, work around limitations
Cross Program Invocations
- Cross-Program Invocation (CPI) là gọi từ chương trình này đến chương trình khác. Mục tiêu là một instruction cụ thể trên chương trình được gọi.
- CPIs được thực hiện bằng cách sử dụng các lệnh invoke hoặc invoke_signed, và cung cấp chữ kí cho các PDAs mà chương trình sở hữu.
- CPIs làm cho các chương trình trong hệ sinh thái Solana hoàn toàn có thể tương tác với nhau vì tất cả các instruction công khai của một chương trình đề có thể được gọi bởi một chương trình khác thông qua CPI
- Bởi vì không thể kiểm soát các tài khoản và dữ liệu gửi đến một chương trình, nên phải xác minh tất cả các tham số được chuyển vào CPI để đảm bảo tính bảo mật của chương trình.
Làm thể nào để tạo CPI
CPI được tạo bằng cách sử dụng invoke hoặc invoke_signed của solana_program crate. Bạn sử dụng invoke về cơ bản để sử dụng các chữ ký giao dịch ban đầu đã được chuyển vào chương trình của bạn. Bạn sử dụng invoke_signed để chương trình của bạn ký cho các PDA mà nó sở hữu.
// Used when there are not signatures for PDAs needed
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> ProgramResult
// Used when a program must provide a 'signature' for a PDA, hence the signer_seeds parameter
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> ProgramResult
Điều quan trọng cần lưu ý với tư cách nhà phát triển là quyết định tài khoản nào sẽ được chuyển vào CPI. Bạn có thể xem CPI như việc xây dựng một instruction khác từ đầu với thông tin đã được chuyển vào chương trình của bạn.
CPI với invoke
invoke(
&Instruction {
program_id: calling_program_id,
accounts: accounts_meta,
data,
},
&account_infos[account1.clone(), account2.clone(), account3.clone()],
)?;
- program_id - khoá công khai của chương trình bạn dự định gọi
- account - danh sách account metadata dưới dạng vector. Bạn cần bao gồm mọi bài khoản mà chương trình được gọi sẽ đọc hoặc ghi vào.
- data - dạng byte buffer đại diện cho dữ liệu được truyền đến chương trình, tương đương instruction data.Instruction có định dạng như sau:
pub struct Instruction {
pub program_id: Pubkey,
pub accounts: Vec<AccountMeta>,
pub data: Vec<u8>,
}
Tuỳ thuộc vào chương trình mà mà bạn đang thực hiện cuộc gọi, có thể có sẵn các functions để tạo Instruction object. Nhiều cá nhân và tổ chức có sẵn các công cụ công khai cũng chương trình của họ để đơn giản hoá việc gọi các chương trình của họ. Điều này tương tự các thư viện Typescript đã sử dụng trong khoá học này như (e.g. @solana/web3.js, @solana/spl-token). Trong ví dụ này, chúng ta sẽ sử dụng spl_token crate để tạo instruction. Trong các trường hợp khác, bạn có thể phải tạo Instruction từ đầu.
Trong khi trường program_id khá đơn giản, thì account và data phức tạp hơn và cần một số mô tả, giải thích.
Cả account và data đề được lại vector, bạn có thể dử dụng vec macro để tạo một vector bằng cách sử dụng như sau:
let v = vec![1, 2, 3];
assert_eq!(v[0], 1);
assert_eq!(v[1], 2);
assert_eq!(v[2], 3);
Trường accounts của Instruction là một vector thuộc loại AccountMeta. AccountMeta có định dạng như sau:
pub struct AccountMeta {
pub pubkey: Pubkey,
pub is_signer: bool,
pub is_writable: bool,
}
Ghép hai ví dụ lại với nhau sẽ trông như thế này:
use solana_program::instruction::AccountMeta;
vec![
AccountMeta::new(account1_pubkey, true),
AccountMeta::read_only(account2_pubkey, false),
AccountMeta::read_only(account3_pubkey, true),
AccountMeta::new(account4_pubkey, false),
]
Và trường cuối cùng là data byte buffer. Bạn có thể tạo byte buffer trong Rust bằng cách sử dụng vec macro. Có một chức năng đã được triển khai cho phép bạn tạo một vector với độ dài nhất định. Bạn phải xác định dữ liệu được yêu cầu bởi chương trình được gọi và tuần tự hoá chúng trong đoạn mã của bạn. Một số tính năng có sẵn của vector ở đây
let mut vec = Vec::with_capacity(3);
vec.push(1);
vec.push(2);
vec.extend_from_slice(&number_variable.to_le_bytes());
invoke và invoke_signed cũng yêu cầu danh sách đối tượng account_info. Giống như danh sách AccountMeta bạn đã thêm vào instruction, bạn cần phải bao gồm tất cả các accounts mà chương trình bạn đang gọi sẽ đọc hoặc ghi vào.
Vào thời điểm mà bạn thực hiện một CPI trong chương trình của bạn, bạn cần phải sẵn sàng tất cả account_info đã được chuyển vào chương trình của bạn. Bạn sẽ phải xây dựng danh sách account_info cho CPI bằng cách chọn, copu và gửi chúng.
Bạn chỉ có thể sao chép từng đối tượng account_info mà bạn chuyển vào CPI bằng cách sử dụng Clone trait. Nó sẽ trả về một bản sao của account_info.
&[first_account.clone(), second_account.clone(), third_account.clone()]
Tóm lại, bạn có thể thực hiện lệnh gọi tương tự như sau;
invoke(
&Instruction {
program_id: calling_program_id,
accounts: accounts_meta,
data,
},
&[account1.clone(), account2.clone(), account3.clone()],
)?;
Bạn không cần phải bao gồm chữ ký vì Solana runtiom sẽ đi cùng với chữ khí ban đầu được chuyển vào chương trình của bạn. Hãy nhớ rằng, invoke không hoạt động nếu một chữ ký PDA được yêu cầu. Lúc đó, bạn sẽ phải sử dụng invoke_signed.
CPI với invoke_signed
Sử dụng invoke_signed hơi khác một chút vì nó yêu cầu seeds được sử dụng để để lấy bất cứ PDA nào phải ký giao dịch. Chương trình cung cấp chữ ký cho các PDA của chúng bằng hàm invoke_signed. Hai trường đầu tiên của invoke_signed cũng giống invoke, nhưng có thêm trường signers_seeds ở đây.
invoke_signed(
&instruction,
accounts,
&[&["First addresses seed"],
&["Second addresses first seed",
"Second addresses second seed"]],
)?;
Phương pháp và những cạm bẫy
Security checks
Có một số sai lầm phổ biến và những điều cần nhớ khi sử dụng CPI quan trọng đối với tính bảo mật của chương trình của bạn. Thứ nhất, cần nhớ là, chúng ta không kiểm soát được thông tin nào được chuyển vào chương trình của mình. Vì lý do này, điều qua trọng là phải luôn xác minh program_id, accounts và data đượcc chuyển vào CPI. Nếu không có các kiểm tra này, ai đó có thể gửi một giao instruction hoàn toàn khác với dự kiến.
Tất cả accounts và instuction_data phải được kiểm tra ở đâu đó trong chương trình của bạn trước khi dự kiến CPI. Điều quan trọng nữa là đảm bảo rằng bạn đang nhắm mục tiêu về đúng chương trình bạn đang yêu cầu. Mọi thứ giống như bạn đang xây dựng một lệnh từ phía Client.
Common errors
Có một số lỗi phổ biến bạn có thể gặp phải khi xây dựng CPI với thông tin không chính xác. Ví dụ: bạn có thể gặp một thông báo lỗi tương tự như sau:
EF1M4SPfKcchb6scq297y8FPCaLvj5kGjwMzjTM68wjA's signer privilege escalated
Program returned error: "Cross-program invocation with unauthorized signer or writable account"
Điều này có nghĩa bạn đang kí không chính xác các địa chỉ trong message. Nếu bạn đang sử dụng invoke_signed và gặp lỗi này thì có nghĩa seeds của bạn chưa chính xác.
Một lỗi tương tự khác xảy ra là khi một tài khoản được thay đổi, nhưng lại không đánh dấu là có thể thay đổi bên trong cấu trúc AccountMeta.
2qoeXa9fo8xVHzd2h9mVcueh6oK3zmAiJxCTySM5rbLZ's writable privilege escalated
Program returned error: "Cross-program invocation with unauthorized signer or writable account"
Hãy nhớ rằng, bất kì tài khoản nào bị thay đổi phải được đánh dấu là có thể thay đổi.
Reloading an Account
Lưu ý quan trọng, nếu CPI chỉnh sửa một tài khoản thuộc Account<'info, T>, tài khoản đó sẽ không thay đổi trong instruction của chương trình của bạn.
Bạn có thể đọc dữ liệu mới được thay đổi bằng CPI bằng cách gọi reload. Điều đó tương tự như thế này
puppet::cpi::set_data(ctx.accounts.set_data_ctx(), data)?;
ctx.accounts.puppet.reload()?;
if ctx.accounts.puppet.data != 42 {
panic!();
}
Ok(())
Xử lý lỗi Anchor
Tất cả các chương trình đề trả về cùng một loại lỗi ProgramError. Tuy nhiên, khi viết chương trình bằng Anchor, bạn có thể sử dụng AnchorErrror làm abstraction cho ProgramError. Phần này cung cấp thông tin bổ sung khi chương trình bị lỗi, bao gồm:
- Tên và số lỗi
- Vị trí xảy ra lỗi
- Account vi phạm một constraint
pub struct AnchorError {
pub error_name: String,
pub error_code_number: u32,
pub error_msg: String,
pub error_origin: Option<ErrorOrigin>,
pub compared_values: Option<ComparedValues>,
}
Bạn có thể thêm các mã lỗi cho chương trình của mình tương tự như sau:
#[error_code]
pub enum MyError {
#[msg("MyAccount may only hold data below 100")]
DataTooLarge
}
Để trả về một lỗi tuỳ chỉnh, bạn có thể sử dụng err hoặc error. Chúng thêm thông tin tệp và dòng sau đó được Anchor ghi lại để giúp bạn gỡ lỗi.
#[program]
mod hello_anchor {
use super::*;
pub fn set_data(ctx: Context<SetData>, data: MyAccount) -> Result<()> {
if data.data >= 100 {
return err!(MyError::DataTooLarge);
}
ctx.accounts.my_account.set_inner(data);
Ok(())
}
}
#[error_code]
pub enum MyError {
#[msg("MyAccount may only hold data below 100")]
DataTooLarge
}
Ngoài ra, bạn có thể sử dụng require để đơn giản hoá các lỗi trả về.
#[program]
mod hello_anchor {
use super::*;
pub fn set_data(ctx: Context<SetData>, data: MyAccount) -> Result<()> {
require!(data.data < 100, MyError::DataTooLarge);
ctx.accounts.my_account.set_inner(data);
Ok(())
}
}
#[error_code]
pub enum MyError {
#[msg("MyAccount may only hold data below 100")]
DataTooLarge
}
Xử lý transaction lớn
Mỗi instruction hiện tại có một giới hạn tính toán nhất định, mặcd dịnh là 200k, tương tự như thế này:
Nếu vượt quá giới hạn tính toán, instruction sẽ không thể thực hiện và báo lỗi. Tuy nhiên một giao dịch có thể đặt số lượng đơn vị tính toán tối đa mà nó được phép sử dụng và đơn giá tính toán bằng cách bao gồm SetComputeUnitLimit và SetComputeUnitPrice.
Lưu ý: phí ưu tiên giao dịch được tính bằng cách nhân compute units với compute unit price thiết lập bởi transaction thông qua compute budget instructions.
Các giao dịch nên yêu cầu số lượng đơn vị tính toán tối thiểu cần thiết để giảm thiểu phí. Các giao dịch chỉ có thể chứa một compute budget instruction, nếu trùng sẽ dẫn đến lỗi.
Bạn có thể tăng giới hạn tính toán tương tự như ví sau tăng lên 400000 giới hạn tính toán:
const tx = await program.methods
.initializeMint(amount)
.accounts({
user: provider.wallet.publicKey,
mint,
tokenAccount,
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
associatedTokenProgram: anchor.utils.token.ASSOCIATED_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
})
.transaction()
tx.add(
anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({
units: 400000,
}),
)
const txId = await provider.sendAndConfirm(tx)
console.log('Your transaction signature', txId)
Trong program logs ghi nhận 400000 đơn vị tính toán
Program log: Instruction: MintTo
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4401 of 349462 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS consumed 55693 of 400000 compute units
Program Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS success
Program ComputeBudget111111111111111111111111111111 invoke [1]
Program ComputeBudget111111111111111111111111111111 success
Các kiểu tấn công phổ biến
Tham khảo: Các kiểu tấn công phổ biến
Các bài viết liên quan
Workshop 1: Introduction to Solana
Workshop 2: Xây dựng giao diện tương tác với Solana
Workshop 3: Client interaction with common Solana programs (SPLT, Metaplex)
Workshop 4: Use the Rust programming language, Anchor
Workshop 5: Xây dựng chương trình đầu tiên với Anchor
Workshop 6: Xây dựng Program với PDAs và SPLT
Về Sentre Protocol
Sentre Protocol là nền tảng mở All-in-One hoạt động trên mạng lưới Solana, cung cấp DApp Store và giao thức mở giúp tích lũy thanh khoản. Với Sentre:
- Người dùng có thể cài đặt các DApp yêu thích của mình và trải nghiệm thế giới DeFi trên một nền tảng duy nhất;
- Lập trình viên và đối tác có thể phát hành DApp thông qua Sen Store, tận dụng tài nguyên có sẵn và tự do đóng góp cho nền tảng.