MỚI: Helius mua lại Light Protocol
số học trên Solana — cách thực hiện phép toán với chương trình hoặc hợp đồng thông minh Solana
Blog/Kiến thức nền tảng

Số học trên Solana: Các phương pháp tốt nhất để xây dựng ứng dụng tài chính

Đào tạo nhà phát triểnMike MacCana trên XMike MacCana trên LinkedIn
Đọc trong 9 phút

Xin cảm ơn 0xIchigo và Lostin đã biên tập và đóng góp cho bài viết này.

Solana là một môi trường cạnh tranh—dù đang phát triển giao thức cho vay mới, trình tổng hợp, thị trường dự đoán, giải pháp token hóa RWA hay bất kỳ sản phẩm nào khác, bạn cũng có thể muốn gấp rút triển khai lên mainnet.

Tuy nhiên, điều quan trọng cần nhớ là các ứng dụng blockchain, do quản lý giá trị, về bản chất đều là ứng dụng tài chính.

Nếu chưa từng xây dựng ứng dụng tài chính—trên blockchain hoặc trong tài chính truyền thống—bạn cần hiểu tầm quan trọng của toán học tài chính.

Có nhiều tài liệu xuất sắc về các chủ đề lập trình dành riêng cho Solana—kiểm tra những tài khoản nào cần ký từng instruction, chương trình nào sở hữu các tài khoản đang sử dụng, tránh các cuộc tấn công mở lại tài khoản, v.v. Các ràng buộc tài khoản của Anchor giúp việc triển khai nhiều bước kiểm tra này dễ dàng hơn, còn Rust có một số thiết lập mặc định hữu ích, chẳng hạn như phát hiện tràn số và tràn số dưới nguyên ở chế độ Debug.

Nhưng lập trình tài chính an toàn không chỉ gồm các chủ đề dành riêng cho Solana. Chỉ một lỗi trong phép toán token cũng có thể gây thất thoát, lạm phát ngoài ý muốn và khiến người dùng tức giận. Trên Solana, nơi khối lượng giao dịch cao hơn các blockchain khác, các lỗ hổng có thể bị khai thác nhanh hơn nữa.

Nhiều kỹ thuật lập trình tài chính từ lĩnh vực tài chính truyền thống không liên quan đến blockchain—vì vậy chúng không nhận được sự chú ý xứng đáng—nhưng vẫn rất quan trọng để bảo vệ token của người dùng.

Trong bài viết này, chúng ta sẽ tập trung vào:

  • Sử dụng số nguyên và đơn vị nhỏ nhất
  • Tránh mất độ chính xác — nhân trước rồi chia
  • Chính sách làm tròn nhất quán
  • Tính lãi mà không dùng số thực dấu phẩy động

Sử dụng số nguyên và đơn vị nhỏ nhất

Thiếu độ chính xác là một lỗ hổng phổ biến trong hợp đồng thông minh. Các phép toán thiếu chính xác có thể tạo ra lỗ hổng. Chúng ta cần sử dụng số nguyên và đơn vị nhỏ nhất để thực hiện các phép toán an toàn trong ứng dụng Solana.

Hãy xem một ví dụ cơ bản.

Trong tài chính truyền thống, mọi giao dịch bạn từng thực hiện “bằng USD” thực tế đều được tính bằng cent. Nếu sử dụng GBP, giao dịch được tính bằng penny. 

Tương tự, các giao dịch bằng SOL nên được xử lý theo lamport, còn giao dịch bằng USDC nên được xử lý theo phần triệu USDC.

Cent, penny và lamport đều là các loại đơn vị nhỏ nhất (còn gọi là đơn vị cơ sở), và lý do thực hiện mọi phép tính bằng đơn vị nhỏ nhất rất đơn giản: máy tính không thể xử lý số thực dấu phẩy động.

Đây là số một ở dạng nhị phân:

Ba mươi haiMười sáuTámBốnHaiMột
000001

Đây là số chín ở dạng nhị phân:

Ba mươi haiMười sáuTámBốnHaiMột
001001

Nhưng bạn sẽ biểu diễn 0,3 USDC như thế nào? 

Câu trả lời chính xác là: không thể: 

Mã
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

Kết quả là:

Chương trình ghi log: "0.1 + 0.2 = 0.30000000000000004"

Thay vào đó, hãy coi dollar, GBP, SOL, USDC và mọi “đơn vị tiền tệ” khác là một số nguyên theo đơn vị nhỏ nhất tương ứng.

Ví dụ, để cộng 0,1 và 0,2 bằng USDC:

Mã
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);

Kết quả là:

Chương trình ghi log: "100000 + 200000 = 300000"

Sử dụng đơn vị nhỏ nhất sẽ không gây thất thoát.

Nhà phát triển nên sử dụng số chữ số thập phân của token và nhớ rằng không phải mọi token đều có cùng số chữ số thập phân.

Việc sử dụng số nguyên cho lượng token có vẻ hiển nhiên, nhưng cũng cần lưu ý rằng bạn phải sử dụng số nguyên ở mọi nơi, không chỉ với lượng token.

Tỷ lệ phần trăm

Tỷ lệ phần trăm nên được biểu diễn bằng số nguyên. Hầu hết mọi người sử dụng điểm cơ bản (đôi khi gọi là “bip”), được ký hiệu là bps.

Ví dụ, 4,74% tương ứng với 474 bps.

Lãi kép

Không nên tính lãi kép bằng e (số Euler), đại diện cho giới hạn của lãi kép khi tần suất ghép lãi tiến tới vô hạn.

Mặc dù e cho phép “ghép lãi liên tục”, về cơ bản tạo thành một đường cong mượt cho lãi kép, bản thân e lại được biểu diễn dưới dạng số thực dấu phẩy động trong Rust.

Khi sử dụng e thay cho các cơ chế khác, bạn vừa gặp lỗi làm tròn vừa nhận được kết quả khác.

Chúng ta sẽ minh họa cả hai vấn đề này ở phần sau của bài viết.

Tràn số và tràn số dưới

Rust lưu trữ số nguyên dưới dạng các biến có kích thước cố định. Điều này có nghĩa là tùy vào việc biến có dấu hay không dấu, nó chỉ có thể chiếm một lượng bộ nhớ nhất định.

Ví dụ, kiểu u8 có thể chứa mọi giá trị từ 0 đến 255. Tuy nhiên, nếu lưu một giá trị nằm ngoài phạm vi đó, chúng ta sẽ gặp hiện tượng tràn số hoặc tràn số dưới.

Tràn số là gì?

Tràn số xảy ra khi giá trị vượt quá khả năng lưu trữ tối đa của kiểu biến, khiến nó quay vòng về giá trị nhỏ nhất.

Ví dụ, nếu cố lưu 256 vào một u8, giá trị sẽ quay vòng về 0. 257 sẽ quay vòng về 1, 258 về 2, 511 về 255 và 512 lại về 0.

Tràn số dưới là gì?

Tràn số dưới xảy ra khi giá trị thấp hơn giá trị nhỏ nhất có thể và quay vòng về giá trị lớn nhất. Với một u8, -1 sẽ quay vòng về 255, -2 về 254, v.v. Tóm lại, tràn số dưới giống như tràn số, nhưng diễn ra theo hướng ngược lại.

Mặc dù Rust có một số cơ chế kiểm tra khiến chương trình panic trong thời gian chạy khi xảy ra tràn số hoặc tràn số dưới, các bước kiểm tra này không được đưa vào khi biên dịch ở chế độ release. Hơn nữa, toolchain đóng vai trò thiết yếu trong môi trường phát triển Solana mặc định biên dịch các chương trình Solana ở chế độ release. 

Để tìm hiểu thêm về tràn số, tràn số dưới và cách giảm thiểu chúng, hãy xem hướng dẫn bảo mật chương trình Solana của chúng tôi.

Nhân trước, rồi chia

Chia trước khi nhân thường mang lại cảm giác tự nhiên—hãy lấy ví dụ về việc xây dựng một thị trường dự đoán. Khi người dùng đặt cược vào một kết quả thắng, chúng ta phải tính khoản thanh toán thắng cược. Người thắng trong thị trường dự đoán được trả thưởng dựa trên tỷ lệ tiền cược của họ trong tổng tiền cược cho kết quả thắng. Nếu tính nhẩm, điều này tương ứng rất rõ với:

quỹ thắng cược ÷ tổng tiền cược cho kết quả thắng × số tiền cược 

Cách này có vẻ rất tự nhiên.

Trước tiên, chúng ta chia quỹ thắng cược thành các phần nhỏ, mỗi phần đại diện cho 1 “suất” tiền thắng, sau đó tính xem mỗi người được nhận bao nhiêu suất.

Nhưng nếu nhân trước, chúng ta sẽ nhận được kết quả trung gian lớn hơn. Nhờ đó, tác động của lỗi làm tròn từ phép chia sau đó sẽ giảm đi. 

quỹ thắng cược × số tiền cược ÷ tổng tiền cược cho kết quả thắng

Ví dụ về thị trường dự đoán

Dưới đây là một minh họa nhanh. Nếu con người thực hiện phép tính này thay vì máy tính, đáp án đúng sẽ là 10,5.

Hãy thử chia trước: 

Mã
let answer: u128 = 7 / 2 * 3;
msg!("7 / 2 * 3 = {}", answer);

Kết quả là:

Chương trình ghi log: "7 / 2 * 3 = 9"

Khi chia trước, chúng ta sẽ mất 1,5 đơn vị nhỏ nhất.

Nếu nhân trước thì sao?

Mã
let answer: u128 = 7 * 3 / 2;
msg!("7 * 3 / 2 = {}", answer);

Kết quả là:

Chương trình ghi log: "7 * 3 / 2 = 10"

Khi nhân trước, chúng ta chỉ mất 0,5 đơn vị nhỏ nhất do làm tròn. 

Có thể mở rộng khái niệm này thành “hãy thực hiện trước mọi phép tính làm tăng bậc độ lớn”. 

Ví dụ, nếu đang tính một biểu thức phức tạp hơn có sử dụng lũy thừa hoặc căn thức (chẳng hạn như tính rủi ro), hãy tính lũy thừa trước và căn thức sau. Lý do là với số học dấu phẩy cố định, thực hiện phép chia trước có thể làm mất độ chính xác nếu thương bị làm tròn xuống trước khi được nhân.

Sử dụng chính sách làm tròn nhất quán

Một chính sách làm tròn không nhất quán, tạo ra chênh lệch nhỏ chỉ bằng một token, có thể khiến token dường như biến mất hoặc được tạo ra từ hư không. Theo thời gian, điều này có thể làm cạn thanh khoản của chương trình hoặc dẫn đến lạm phát token ngoài ý muốn.

Như Will Thieme từ Orca đã chỉ ra trong bài nói chuyện tại Breakpoint về Cách tránh những cạm bẫy phổ biến trong chương trình Solana, việc nhận thêm một token có vẻ không đáng kể.

Tuy nhiên, Solana có các giao dịch thực sự (theo nghĩa tài chính truyền thống) chứa nhiều instruction và có phí giao dịch thấp. Vì vậy, có thể nhồi nhiều instruction riêng lẻ khai thác lỗi lệch một đơn vị vào một giao dịch rồi thực thi với chi phí rất thấp.

Lỗi làm tròn là không thể tránh khỏi nhưng có thể được kiểm soát bằng một chính sách làm tròn nhất quán. Hãy quyết định ngay từ đầu sẽ làm tròn lên hay xuống và cách xử lý giá trị “một nửa” (tức 0,5)—làm tròn nửa lên phổ biến hơn và là yêu cầu bắt buộc trong một số tiêu chuẩn tài chính. Điều quan trọng nhất là áp dụng chính sách đó nhất quán trong toàn bộ mã nguồn.

Những lưu ý khi làm tròn dành riêng cho Rust

Nhà phát triển cần biết một số hàm riêng của Rust có thể gây vấn đề vì các phép làm tròn là nguyên nhân phổ biến dẫn đến mất độ chính xác. Việc lựa chọn phương pháp làm tròn có thể ảnh hưởng đáng kể đến độ chính xác và hành vi của chương trình.

Làm tròn so với làm tròn xuống

Ví dụ, hàm try_round_u64() làm tròn đến số nguyên gần nhất. Nếu muốn xây dựng chương trình chuyển đổi tài sản thế chấp thành thanh khoản, việc làm tròn lên có thể dẫn đến phát hành nhiều token thanh khoản hơn mức được bảo đảm bởi tài sản thế chấp đã cung cấp. Thay vào đó, chúng ta nên sử dụng hàm try_floor_u64() để làm tròn xuống số nguyên gần nhất.

Các hàm số học bão hòa

Ngoài ra, nhà phát triển thường sử dụng các hàm số học saturating_* (ví dụ: saturating_add) để giới hạn các giá trị ở mức tối đa và tối thiểu tương ứng, qua đó ngăn tràn số và tràn số dưới. Tuy nhiên, những hàm này có thể gây mất độ chính xác khó nhận thấy.

Ví dụ, nếu hàm của bạn nhân số tiền giao dịch với hệ số phần thưởng khiến kết quả vượt quá giá trị tối đa của kiểu biến dùng cho tích, người dùng sẽ nhận được phần thưởng thấp hơn mức đáng ra họ được hưởng.

Điều này rất quan trọng cần ghi nhớ, đặc biệt vì chương trình của bạn nên sử dụng số học dấu phẩy cố định.

Tính lãi mà không dùng số thực dấu phẩy động

Một kỹ thuật phổ biến để tính lãi kép là sử dụng số Euler e, nhưng e là một số thực dấu phẩy động! Hãy tránh sử dụng số thực dấu phẩy động để tính lãi. Thay vào đó, hãy sử dụng số học dấu phẩy cố định.

Thư viện spl-math của Solana có PreciseNumber. Như bạn có thể hình dung, kiểu này hoạt động trong môi trường Rust hạn chế hơn của Solana và có thể biểu diễn các phân số thập phân rất nhỏ (tối đa 12 chữ số thập phân) mà vẫn duy trì độ chính xác tuyệt đối.

Mã
use spl_math::precise_number::PreciseNumber;

fn calculate_compound_interest(
   principal: u128,
   rate_basis_points: u128,
   time: u128,
   compounds_per_year: u128,
) -> u128 {
   // Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
   // Where rate_basis_points is expressed in basis points (500 for 5%)

   // Convert principal to PreciseNumber
   let principal = PreciseNumber::new(principal).unwrap();

   // Convert basis points to decimal percentage (divide by 10000)
   let rate = PreciseNumber::new(rate_basis_points)
       .unwrap()
       .checked_div(&PreciseNumber::new(10_000).unwrap())
       .unwrap();

   // Calculate rate/compounds_per_year
   let rate_per_period = rate
       .checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
       .unwrap();

   // Calculate 'base', which is 1 + rate/compounds_per_year
   let one = PreciseNumber::new(1).unwrap();
   let base = rate_per_period.checked_add(&one).unwrap();

   // Calculate 'total_periods', which is compounds_per_year * time
   let total_periods = compounds_per_year.checked_mul(time).unwrap();

   // Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
   let compound_factor = base.checked_pow(total_periods).unwrap();

   // Calculate result = principal * compound_factor
   principal
       .checked_mul(&compound_factor)
       .unwrap()
       .to_imprecise()
       .unwrap()
}

Bạn có thể thấy sự khác biệt bằng cách chạy mã:

  • Chương trình ghi log: "Sử dụng spl-math PreciseNumber"
  • Chương trình ghi log: "Khoản đầu tư $1000 với lãi suất 5% trong 5 năm, ghép lãi 1 lần mỗi năm:"
  • Chương trình ghi log: "Số tiền cuối cùng: $1276"

So với khi sử dụng e:

  • Chương trình ghi log: " Sử dụng e (đừng làm như vậy)"
  • Chương trình ghi log: "Khoản đầu tư $1000 với lãi suất 5% trong 5 năm, ghép lãi 1 lần mỗi năm:"
  • Chương trình ghi log: "Số tiền cuối cùng: $1284"

Kết luận

Phép toán token là một chủ đề nhàm chán, nhưng nếu không chú ý, nó có thể gây ra những tình huống gay cấn mà bạn không hề mong muốn. Các ứng dụng on-chain của bạn phải xử lý token với độ chính xác và tính bảo mật mà người dùng yêu cầu.

Trong bài viết này, chúng ta đã tìm hiểu cách sử dụng số nguyên và đơn vị nhỏ nhất, thực hiện phép nhân, tránh mất độ chính xác, duy trì chính sách làm tròn nhất quán và tính lãi mà không dùng số thực dấu phẩy động.

Sau khi nắm vững các nguyên tắc số học cơ bản này, hãy nhờ một bên khác—cụ thể là một công ty kiểm toán chuyên về Solana—đánh giá mã nguồn trước khi triển khai lên mainnet.

Tài liệu bổ sung

Đăng ký nhận tin từ Helius

Luôn cập nhật những thông tin mới nhất về phát triển Solana và nhận thông báo khi chúng tôi đăng bài