Bài 6: Bắt tay xây dựng DApp đầu tiên trên Solana (Phần 1)

Bài viết này sẽ tổng hợp các kiến thức cần có và hướng dẫn để bạn bắt đầu xây dựng DApp trên Solana. Tham khảo ngay!

Bài 6: Bắt tay xây dựng DApp đầu tiên trên Solana (Phần 1)

Sau khi hoàn thành 5 bài đầu tiên trong chuỗi 10 bài, chúng ta đã được trang bị các kiến thức cơ bản nhất để xây dựng một DApp hoàn chỉnh. Để giúp bạn đọc có thể hệ thống lại kiến thức, bài viết xin nhắc lại các kiến thức đã học gồm:

  • ReactJS - giúp phát triển ứng dụng nền Web.
  • Ant Design - giúp xây dựng nhanh chóng giao diện người dùng bằng các thành phần dựng sẵn.
  • Redux Toolkit - giúp quản lý trạng thái của ứng dụng với số lượng dữ liệu và trạng thái phức tạp.
  • Anchor - giúp phát triển chương trình chạy được trên chuỗi khối. Solana dùng thuật ngữ program, hoặc Ethereum dùng thuật ngữ smart contract để ám chỉ chương trình này.

Trong bài này chúng ta sẽ kết hợp tất cả chúng lại để xây dựng một ứng dụng bỏ phiếu một cách hoàn chỉnh nhất.

Hệ thống bỏ phiếu điện tử có trọng số (Weighted eVoting System)

Trong phần này chúng ta sẽ đi phân tích xem để hiểu được hệ thống bỏ phiếu điện tử có trọng số (sau đây sẽ gọi ngắn gọn là hệ thống) có những đặc tính cơ bản nào.

Phiếu bầu có trọng số

Đối với các hệ thống bỏ phiếu phổ biến, đa số chúng đều là không trọng số. Nghĩa là mọi người bỏ phiếu (voter) đều có “sức mạnh” như nhau và bằng chính xác 1 lá phiếu. Dù bạn rất nổi tiếng, hay bạn là một sinh viên, thì lá phiếu của bạn đều chỉ được tính là 1. Ngược lại, trong hệ thống có trọng số, yếu tố “sức mạnh” được xem xét đến trong quá trình kiểm phiếu. Cụ thể hơn,

trên môi trường blockchain, số lượng một token cụ thể bạn đang nắm sẽ đại diện cho sức mạnh lá phiếu của bạn.

Ở hình 1, người bỏ phiếu có 10 tokens và anh ấy có thể dùng 10 tokens đó để đại diện cho “sức mạnh” lá phiếu bầu. Ví dụ, anh ấy bỏ phiếu cho Candidate #1, thì hệ thống sẽ ghi nhận Cadidate #1 được cộng thêm 10 điểm.

Hình 1. Hệ thống bỏ phiếu có trọng số.

Double Spend

Double Spend hay “Dùng hai lần” là một hình thức gian lận giúp người dùng có thể sử dụng nhiều hơn một lần bằng 1 số lượng tokens cụ thể. Trong phạm vi hệ thống, sử dụng được hiểu theo nghĩa là bỏ phiếu. Giả sử như sau khi bỏ phiếu bằng ví số 1, người dùng cố tình chuyển số tokens ban đầu sang một ví số 2 nhằm thực hiện lại hành vi bỏ phiếu không hợp lệ. Tiếp tục như vậy, với chỉ 10 tokens, người dùng có thể bỏ phiếu vô số lần.

Để đảm bảo được tính công bằng, với mỗi lượt bỏ phiếu, số token của người dùng phải được khoá trong thời gian bỏ phiếu. Cơ chế khoá được hiện thực đơn giản bằng cách chuyển token người dùng vào một ví tạm thời và chỉ hệ thống mới có thể kiểm soát ví đó. Hết thời hạn bỏ phiếu, số token sẽ được trả lại chính xác cho người bỏ phiếu.

Hình 2. Token người dùng sẽ được khoá tạm thời trong thời gian bỏ phiếu. Số token này sẽ đc hoàn trả khi kết thúc bỏ phiếu.

Hiện thực hệ thống

Khai báo Schema

Candidate

Mỗi ứng viên sẽ được tạo một tài khoản trên chuỗi khối nhằm ghi nhận số lượng người bỏ phiếu cũng như quản lý các thông tin cần thiết cho việc bỏ phiếu. Để cho đơn giản, ở đây bài viết lược bỏ đi khá nhiều thông tin và chỉ giữ lại các thông tin cơ bản nhất.

Hình 3. Thông tin ứng viên (Candidate schema)
  • mint: Loại token được dùng để bỏ phiếu.
  • amount: số lượng token bầu cho ứng viên.
  • start_date: Thời gian bắt đầu cho phép bỏ phiếu
  • end_date: Thời gian kết thúc bỏ phiếu.

Ballot

Phiếu bầu dùng để lưu trữ thông tin bỏ phiếu của từng người tham gia hệ thống. Lá phiếu sẽ dùng để xác thực việc hoàn trả token sau này.

Hình 4: Thông tin phiếu bầu (Ballot schema)

Khai báo Instructions

Hàm initialize_candidate:

Đây là hàm giúp khởi tạo ứng viên mới. Khi muốn tạo mới ứng viên cho việc bầu cử cần gọi hàm initialize_candidate. Ứng viên sẽ được tạo mới với các dữ liệu mặc định.

Khai báo Context
#[derive(Accounts)]
pub struct InitializeCandidate<'info> {
 #[account(mut)]
 pub authority: Signer<'info>,
 #[account(
   init,
   payer = authority,
   space = Candidate::SIZE,
 )]
 pub candidate: Account<'info, Candidate>,
 #[account(seeds = [b"treasurer".as_ref(), &candidate.key().to_bytes()], bump)]
 /// CHECK: Just a pure account
 pub treasurer: AccountInfo<'info>,
 pub mint: Box<Account<'info, token::Mint>>,
 #[account(
   init,
   payer = authority,
   associated_token::mint = mint,
   associated_token::authority = treasurer
 )]
 pub candidate_token_account: Account<'info, token::TokenAccount>,
 // System Program Address
 pub system_program: Program<'info, System>,
 pub token_program: Program<'info, token::Token>,
 pub associated_token_program: Program<'info, associated_token::AssociatedToken>,
 pub rent: Sysvar<'info, Rent>,
}

authority: Địa chỉ ví thực hiện và trả phí giao dịch

  • candidate: Địa chỉ ứng viên. Mỗi ứng viên khác nhau có địa chỉ candidate khác nhau.
  • treasurer: Địa chỉ PDA quản lý candidate token account. Được tạo thành với seeds là “treasurer” và địa chỉ của candidate. Vậy nên candidate khác nhau có treasurer khác nhau.
  • Đối với những loại account không có kiểu dữ liệu cụ thể, cần thêm: /// CHECK: Just a pure account
  • mint: Loại token được dùng để bỏ phiếu
  • candidate_token_account: Địa chỉ Token Account dùng để khoá tạm thời token để ngăn tấn công Double Spend.
Viết hàm thực thi
pub fn exec(ctx: Context<InitializeCandidate>, start_date: i64, end_date: i64) -> Result<()> {
    let candidate = &mut ctx.accounts.candidate;
    candidate.start_date = start_date;
    candidate.end_date = end_date;
    candidate.amount = 0;
    candidate.mint = ctx.accounts.mint.key();
    Ok(())
}

Khi hàm thực thi initialize_candidate được gọi, chương trình sẽ khởi tạo một Candidate mới với:

  • start_dateend_date được truyền ở thông số đầu vào.
  • Giá trị amount ban đầu bằng 0.
  • Loại token dùng để bầu được định nghĩa trong Context.

Hàm vote

Đây là hàm bầu cử cho ứng viên được lựa chọn. Sau khi gọi hàm vote, ứng viên được bầu sẽ tăng số lượng phiếu bầu tương ứng với số token chỉ định. Token dùng để bầu sẽ được khoá tạm thời nhằm ngăn chặn Double Spend.

Khai báo Context
#[derive(Accounts)]
pub struct Vote<'info> {
 // TODO: Customize account address
 #[account(mut)]
 pub authority: Signer<'info>,
 #[account(mut, has_one = mint)]
 // Candidate accounts
 pub candidate: Account<'info, Candidate>,
 #[account(seeds = [b"treasurer".as_ref(), &candidate.key().to_bytes()], bump)]
 /// CHECK: Just a pure account
 pub treasurer: AccountInfo<'info>,
 pub mint: Box<Account<'info, token::Mint>>,
 #[account(
   mut,
   associated_token::mint = mint,
   associated_token::authority = treasurer
 )]
 pub candidate_token_account: Account<'info, token::TokenAccount>,
 #[account(
   init_if_needed,
   payer = authority,
   space = Ballot::SIZE,
   seeds = [b"ballot".as_ref(), &candidate.key().to_bytes(), &authority.key().to_bytes()],
   bump
 )]
 pub ballot: Account<'info, Ballot>,
 #[account(
   mut,
   associated_token::mint = mint,
   associated_token::authority = authority
 )]
 pub voter_token_account: Account<'info, token::TokenAccount>,
 // System Program Address
 pub system_program: Program<'info, System>,
 pub token_program: Program<'info, token::Token>,
 pub associated_token_program: Program<'info, associated_token::AssociatedToken>,
 pub rent: Sysvar<'info, Rent>,
}

authority, candidate, treasurer, mint, candidate_token_account: có ý nghĩa tương tự hàm initialize_candidate.

  • ballot: Địa chỉ phiếu bầu của người đi bầu và cũng là một PDA. Lần đầu bầu, phiếu bầu này sẽ được tạo mới. Những lần bầu tiếp theo chỉ cần cập nhật dữ liệu trên địa chỉ đã tạo.
  • voter_token_account: Địa chỉ ví của cử tri chứa loại token dùng để bầu cử (tương ứng với mint trong thông tin ứng viên).
Viết hàm thực thi
pub fn exec(ctx: Context<Vote>, amount: u64) -> Result<()> {
 let candidate = &mut ctx.accounts.candidate;
 let ballot = &mut ctx.accounts.ballot;

 let now = Clock::get().unwrap().unix_timestamp;
 if now < candidate.start_date || now > candidate.end_date {
   return err!(ErrorCode::NotActiveCandidate);
 }

 let transfer_ctx = CpiContext::new(
   ctx.accounts.token_program.to_account_info(),
   token::Transfer {
     from: ctx.accounts.voter_token_account.to_account_info(),
     to: ctx.accounts.candidate_token_account.to_account_info(),
     authority: ctx.accounts.authority.to_account_info(),
   },
 );
 token::transfer(transfer_ctx, amount)?;

 candidate.amount += amount;

 ballot.authority = ctx.accounts.authority.key();
 ballot.candidate = candidate.key();
 ballot.amount += amount;

 Ok(())
}



Khi hàm thực thi hàm Vote được gọi, chương trình sẽ khởi tạo một Ballot mới nếu cần thiết. Sau đó kiểm tra xem còn trong thời hạn được phép bầu hay không. Nếu chưa đến hoặc quá thời gian bầu thì báo lỗi, còn không sẽ thực hiện bầu cho ứng viên. Chuyển số token tương ứng với số lượng bầu cho ứng viên vào kho chứa token của Candidate để khoá tạm thời. Sau đó cập nhật lại số lượng bầu cho ứng viên và phiếu bầu.

Hàm close:

Đây là hàm để lấy lại token đã bầu sau khi thời gian bầu cử kết thúc. Nếu thời gian bầu cử chưa kết thúc không cho phép close. Token đã dùng để bầu cử sẽ được hoàn trả cho cử tri và xoá bỏ phiếu bầu.

Khai báo context
#[derive(Accounts)]
pub struct Close<'info> {
 // TODO: Customize account address
 #[account(mut)]
 pub authority: Signer<'info>,
 #[account(mut, has_one = mint)]
 // Candidate accounts
 pub candidate: Account<'info, Candidate>,
 #[account(seeds = [b"treasurer", &candidate.key().to_bytes()], bump)]
 /// CHECK: Just a pure account
 pub treasurer: AccountInfo<'info>,
 pub mint: Box<Account<'info, token::Mint>>,
 #[account(
   mut,
   associated_token::mint = mint,
   associated_token::authority = treasurer
 )]
 pub candidate_token_account: Account<'info, token::TokenAccount>,
 // Wallet accounts
 #[account(
   mut,
   close = authority,
   seeds = [b"ballot".as_ref(), &candidate.key().to_bytes(), &authority.key().to_bytes()],
   bump
 )]
 pub ballot: Account<'info, Ballot>,
 #[account(
   mut,
   associated_token::mint = mint,
   associated_token::authority = authority
 )]
 pub voter_token_account: Account<'info, token::TokenAccount>,
 // System Program Address
 pub system_program: Program<'info, System>,
 pub token_program: Program<'info, token::Token>,
 pub associated_token_program: Program<'info, associated_token::AssociatedToken>,
 pub rent: Sysvar<'info, Rent>,
}

authority, candidate, treasurer, mint, candidate_token_account: có ý nghĩa tương tự hàm initialize_candidate.

  • ballot, voter_token_account: có ý nghĩa tương tự hàm vote. Tuy nhiên Ballot sẽ được thu hồi sau khi thực hiện hàm close.
Viết hàm thực thi
pub fn exec(ctx: Context<Close>) -> Result<()> {
 let candidate = &mut ctx.accounts.candidate;
 let ballot = &mut ctx.accounts.ballot;

 let now = Clock::get().unwrap().unix_timestamp;
 if now < candidate.end_date {
   return err!(ErrorCode::EndedCandidate);
 }

 let seeds: &[&[&[u8]]] = &[&[
   "treasurer".as_ref(),
   &candidate.key().to_bytes(),
   &[*ctx.bumps.get("treasurer").unwrap()],
 ]];

 let transfer_ctx = CpiContext::new_with_signer(
   ctx.accounts.token_program.to_account_info(),
   token::Transfer {
     from: ctx.accounts.candidate_token_account.to_account_info(),
     to: ctx.accounts.voter_token_account.to_account_info(),
     authority: ctx.accounts.authority.to_account_info(),
   },
   seeds,
 );
 token::transfer(transfer_ctx, ballot.amount)?;
 ballot.amount = 0;

 Ok(())
}

Khi hàm thực thi close được gọi, chương trình sẽ kiểm tra xem đã hết thời hạn bầu hay chưa.

  • Nếu chưa thì báo lỗi.
  • Nếu đã hết thời hạn bầu, hệ thống sẽ hoàn trả số token tương ứng với số lượng đã bầu. Sau đó thu hồi lại phiếu bầu.

Tham khảo

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.