NEAR Live Contract Review | Phần 2: Staking Pool Contract

34 min read
To Share and +4 nLEARNs

Giới thiệu

Chào mọi người, tôi là Eugene và hôm nay chúng ta sẽ tiếp tục series review hợp đồng thông minh với staking pool contract. Đây là contract được NEAR Protocol sử dụng ngay thời điểm hiện tại để vận hành hệ thống Proof of stake. Tất cả các validators đang chạy trên NEAR Protocol đều đang hoạt động dựa theo contract này. Các validator không kiểm soát những tài khoản đang stake NEAR token để thực hiện cơ chế proof of stake, chúng chỉ cung cấp các pool để stake và chạy các node, nhiệm vụ stake NEAR token được thực thi bởi hợp đồng thông minh mà chúng ta sẽ review ngày hôm nay. Contract staking này nằm trong danh sách các core contract của NEAR Protocol và nó cũng phức tạp hơn so với voting contract mà chúng ta đã xem xét ở phần 1 của series này. Hôm nay chúng ta cũng sẽ không đi sâu vào phần near_bindgen hay ngôn ngữ Rust như phần trước mà sẽ tập trung hơn vào việc phân tích logic và kiến thức liên quan tới NEAR Protocol. Các bạn có thể xem mã nguồn của staking pool contract tại github của NEAR Protocol

Bạn cũng có thể xem video gốc review contract này trên youtube:

lib.rs

Struct chính của contract (Constructor Structure)

Giống như ở phần review trước, chúng ta cũng sẽ xem xét struct chính của contract staking này – StakingContract. Bạn có thể thấy các macro quen thuộc của NEAR SDK như near_bindgen, BorshSerialize và BorshDeserialize. Struct này cũng có nhiều trường dữ liệu hơn so với VotingContract mà chúng ta review ở bài trước, bên cạnh đó là một số phần comment để giải thích ý nghĩa của mỗi trường dữ liệu. Logic của hợp đồng này cho phép bất cứ ai cũng có thể nạp và stake một số lượng NEAR token vào staking pool. Từ đó chúng ta có thể gom số dư từ nhiều người (chúng ta sẽ sử dụng thuật ngữ account thay cho người stake NEAR token) thành một lượng lớn cổ phần (stake) và nếu số lượng token gom lại đủ lớn chúng có thể thỏa mãn điều kiện để trở thành một validator trên mạng lưới của NEAR Protocol. Hiện tại, NEAR Protocol đang cung cấp một số lượng hữu hạn suất để trở thành một validator trên một shard, cụ thể là có tối đa 100 suất. Bạn có thể hiểu điều kiện có một suất để trở thành validator như sau: nếu bạn lấy tổng tất cả lượng token NEAR đã được stake và chia cho 100 thì kết quả nhận được sẽ là lượng token tối thiểu để thỏa mãn một suất trên một shard. Hợp đồng này là một hợp đồng độc lập không có bất kỳ khóa truy cập nào do chủ sở hữu của pool kiểm soát. Trong trường hợp này, id của chủ sở hữu của pool được cung cấp trong phương thức khởi tạo.

Phương thức khởi tạo (Initialization method)

Phương thức khởi tạo nhận vào 3 tham số. Tham số đầu tiên là owner_id, đại diện cho account id của chủ sở hữu account. Chủ sở hữu sẽ có một số quyền hạn đối với hợp đồng này mà tất cả các tài khoản khác không có. Một trong số đó là quyền được bỏ phiếu cho các quyết định của staking pool hay cụ thể là quyền gọi tới phương thức vote của hợp đồng VotingContract mà chúng ta đã review ở bài viết trước. 

Trong phương thức vote() trên, đầu tiên chúng ta phải gọi tới phương thức assert_owner(). Ở phương thức này chúng ta xác nhận account ID của contract được gọi trước đó trong chuỗi các lần call contract có phải chính là account ID của chủ sở hữu đang chạy validator node hay không, vì chỉ có chủ sở hữu staking pool mới có thể gọi tới phương thức vote(). Mã nguồn của phương thức assert_owner() như sau:

Sau khi xác thực chủ sở hữu của staking pool, phương thức vote() sẽ thực hiện một số logic khác (chúng ta sẽ bàn kỹ hơn về logic này ở phần sau).

Quay trở lại với mã nguồn của phương thức khởi tạo:

Bên cạnh tham số owner_id thì phương thức khởi tạo còn có một tham số khác là stake_public_key. Khi bạn thực hiện hành động stake trên NEAR Protocol, bạn cần cung cấp public key (khóa công khai) mà validator node sẽ dùng để ký trên các thông điệp (message). Public key này nên khác với các access key khác vì validator node của bạn có thể sẽ chạy ở một trung tâm dữ liệu dễ bị tấn công và nếu trường hợp này xảy ra đi chăng nữa thì tài khoản của bạn vẫn sẽ an toàn, chỉ có mạng lưới các node là chịu tấn công mà thôi. Kẻ tấn công sẽ không thể lấy cắp tài sản của bạn và bạn cũng sẽ dễ dàng thay đổi public key so với việc sử dụng một access key không tốt. Và tham số cuối cùng của phương thức khởi tạo là reward_fee_fraction. Đây chính là khoản hoa hồng từ phần thưởng staking của những người stake NEAR token vào pool mà chủ sở hữu của staking pool sẽ nhận được khi họ vận hành validator.

Sau đây chúng ta sẽ phân tích kỹ hơn struct RewardFeeFraction.

Cấu trúc dữ liệu này bao gồm hai thành phần là tử số và mẫu số đại diện cho khoản hoa hồng mà chủ sở hữu staking pool sẽ nhận được, cấu trúc này giúp chúng ta thực hiện được một việc dạng như “tôi sẽ nhận 1% tiền thưởng của bạn vì tôi đã vận hành staking pool này”. Ví dụ, bạn stake 1 000 000 token NEAR và nhận được phần thưởng staking là 10 000 token, khi đó chủ sở hữu của staking pool sẽ nhận được 1% của 10 000 token này là 100 token NEAR. 

Một lưu ý ở đây là khi bạn thực hiện các phép nhân thì kiểu dữ liệu float có thể sẽ cho ra một số kết quả không lường trước được. Ví dụ, với trường hợp của phân số, bạn có thể sử dụng nhiều bit hơn để tính toán. Khi thực hiện phép chia với phân số, đầu tiên bạn nhân một số có kiểu dữ liệu u128 với tử số (phép nhân này có thể dẫn đến hiện tượng overflow kiểu dữ liệu u128 vì thế nên chúng ta sẽ thực hiện phép nhân trong phạm vi của kiểu dữ liệu u256). Sau đó chia kết quả vừa nhận được cho mẫu số và nhận được một kết quả mới thuộc phạm vi kiểu dữ liệu u128. Điều này có thể dẫn tới sai sót trong quá trình làm tròn số khi bạn tính toán. Vì vậy bạn không cần sử dụng kiểu dữ liệu float với nhiều chữ số thập phân hơn, vì chúng cho ra kết quả không khác mấy so với việc sử dụng kiểu dữ liệu u256. Ngôn ngữ Solidity ban đầu cũng không hỗ trợ kiểu dữ liệu float và NEAR SDK ban đầu cũng vậy, nhưng việc này lại gây ra một số vấn đề trong việc debug với kiểu dữ liệu String trong  Rust. Vì vậy, chúng tôi vẫn quyết định sẽ hỗ trợ kiểu dữ liệu float trong SDK và đặc biệt là việc chúng tôi đã chuẩn hóa nó ở phía máy ảo. Vấn đề lớn nhất với kiểu dữ liệu float là các hành vi không xác định trước xung quanh một số giá trị nhất định. Ví dụ, khi bạn có một số thực(float) có vô hạn chữ số sau dấu phẩy thì các bit của kiểu dữ liệu float sẽ lưu các giá trị gì? Tuy nhiên, chúng tôi đã chuẩn hóa vấn đề này nên bạn có thể sử dụng kiểu dữ liệu float, nên vẫn oke khi sử dụng kiểu float ở phía máy ảo(vm).

Có một thói quen tốt khi code phương thức khởi tạo là chúng ta cần kiểm tra xem state có tồn tại không. Sau đó, chúng ta kiểm tra các trường dữ liệu đầu vào. Cụ thể với phương thức assert_valid() của cấu trúc dữ liệu RewardFeeFraction, chúng ta kiểm tra xem trường dữ liệu mẫu số có khác 0 hay không, tiếp theo chúng ta thấy câu lệnh điều kiện else để kiểm tra xem trường dữ liệu tử số có giá trị nhỏ hơn hoặc bằng giá trị của mẫu số hay không (tức kiểm tra xem giá trị của phân số có nhỏ hơn hoặc bằng 1 hay không). Điều này rất quan trọng để tránh các lỗi logic sau này. Tiếp theo, chúng ta kiểm tra xem account có hợp lệ không. Contract này được viết trước khi chúng tôi có một số tiêu chuẩn hỗ trợ đánh giá như hiện tại. Ví dụ, chúng tôi có một account ID thuộc kiểu dữ liệu JSON giúp tự động kiểm tra trong quá trình deserialization, nếu như account ID không hợp lệ, nó sẽ panic. Sau đó, chúng ta sẽ lấy được số dư trong tài khoản hiện tại của hợp đồng staking. Số dư này thường đủ lớn để trả khoản phí lưu trữ hợp đồng trên blockchain. Tiếp theo, chúng ta phân bổ một số lượng token cho STAKE_SHARE_PRICE_GUARANTEE_FUND. Staking pool đảm bảo một số việc cho các local contract. Cụ thể là khi bạn stake vào staking pool thì bạn vẫn có thể rút về ví với số lượng token ít nhất bằng với số token mà bạn đã stake, và các thao tác rút tiền hoặc nạp tiền của hợp đồng này cũng chỉ tốn phí không quá 1000 000 000 000 yocto NEAR. 

Quỹ STAKE_SHARE_PRICE_GUARANTEE_FUND có khoảng 1 nghìn tỷ yocto NEAR và chúng ta thường tiêu tốn khoảng 1 đến 2 nghìn tỷ yocto NEAR cho các lỗi làm tròn số. Chúng ta cũng cần ghi lại số dư đã stake cho contract này. Đây là điều cần thiết để hạn chế sự khác biệt khi thực hiện làm tròn số. Tiếp theo, chúng ta xác minh tài khoản đã stake hay chưa vì nếu chưa stake thì các logic phía sau có thể không hoạt động, do đó cần phải khởi tạo contract này trước khi stake. Cuối cùng, chúng ta khởi tạo struct, nhưng không trả về giá trị của struct này ngay lập tức, chúng ta chỉ dừng lại ở việc tạo ra một cấu trúc StakingContract.

Sau khi khởi tạo struct, chúng ta sẽ phát hành một restaking transaction. Điều này rất quan trọng vì chúng ta cần chắc chắn rằng staking key đã được cung cấp bởi người dùng là một khóa ràng buộc nhằm tạo ra một staking transaction. Một khi transaction này được tạo ra từ contract, chúng ta sẽ tiến hành xác thực staking key, nếu nó không hợp lệ thì sẽ xuất hiện lỗi và tất cả quá trình khởi tạo staking pool sẽ thất bại. Nếu như bạn truyền vào một giá trị không hợp lệ cho tham số stake_public_key của phương thức khởi tạo thì việc hợp nhất và triển khai contract cùng với mọi thứ xảy ra trong một lô các giao dịch sẽ được hoàn nguyên. Điều này cũng rất quan trọng vì nếu cho phép sử dụng khóa không hợp lệ thì một ai đó có thể chặn việc stake của bạn. Khi unstake, bạn sẽ nhận lại được token của mình trong vòng 4 epochs.

Tôi nghĩ là đến thời điểm này, tôi đã giải thích khá nhiều chi tiết trước khi bắt đầu nói một cách tổng quan về cách hoạt động của staking contract và các số dư.

Hãy cùng tìm hiểu các khái niệm về cách mà chúng ta có thể phân phối phần thưởng cho các tài khoản đã stake trong thời gian cố định (constant time) khi mỗi epoch trôi qua. Đây là một vấn đề quan trọng đối với hầu hết các smart contract. Các hợp đồng muốn thực hiện các phương thức trong thời gian cố định thay vì trong thời gian tuyến tính theo lượng người dùng. Lý do là nếu thực hiện trong lượng thời gian tuyến tính thì khi lượng người dùng tăng, lượng gas cần thiết để vận hành hệ thống cũng sẽ tăng lên tuyến tính và rất có thể chúng ta sẽ hết gas.

Account Structure

Mỗi người dùng ủy quyền (delegated) token vào staking pool được biểu diễn bởi một cấu trúc dữ liệu gồm các trường dữ liệu sau: ‘unstaked’ là số dư trong ví người dùng mà họ đã không (chưa) stake vào pool (tính theo yocto NEAR). Trường dữ liệu tiếp theo là ‘stake_shares’, đại diện cho số dư nhưng không phải tính bằng NEAR mà bằng số lượng stake shares (cổ phiếu cổ phần). Có thể hiểu là khi bạn stake, về cơ bản là bạn mua cổ phiếu mới ở mức giá hiện tại bằng cách chuyển đổi số dư chưa được stake trong tài khoản thành stake shares. Giá của một stake share ban đầu là 1, nhưng theo thời gian nó sẽ tăng lên cùng với phần thưởng và khi tài khoản nhận được phần thưởng, tổng số dư cổ phần của nó sẽ tăng lên, nhưng tổng số lượng cổ phần không thay đổi. Về cơ bản, khi một tài khoản nhận được phần thưởng hoặc một số khoản tiền khác gửi thẳng vào số dư, điều đó sẽ làm tăng số tiền mà bạn có thể nhận được cho mỗi cổ phần được stake. Ví dụ, ban đầu bạn có 1 triệu token NEAR trong tài khoản, giả sử bạn nhận được 1 triệu cổ phiếu (chúng ta tạm không sử dụng đơn vị yocto NEAR ở đây), nếu staking pool nhận về phần thưởng là 10 000 NEAR thì bạn vẫn có 1 triệu cổ phiếu, nhưng lượng cổ phiếu này bây giờ tương đương với 1 010 000 NEAR. Bây giờ nếu một người nào khác muốn stake, họ sẽ mua cổ phiếu nội bộ của hợp đồng với giá của mỗi cổ phiếu là 1.01 NEAR. Khi bạn nhận được một số giải thưởng khác, bạn sẽ không cần phải mua thêm cổ phần mặc dù trong thời gian không đổi thì mọi người chia sẻ phần thưởng tỉ lệ với số lượng cổ phần họ có. Bây giờ, nếu bạn unstake thì về cơ bản bạn đang bán đi cổ phần của mình hoặc đốt chúng bằng cách sử dụng khái niệm token có thể thay thế (fungible token). Bạn bán đi cổ phần với giá hiện tại của nó thì số lượng cổ phần cũng như cổ phiếu của bạn cũng sẽ giảm đi, hoặc bạn mua thêm thì tổng số dư cổ phần và tổng số cổ phần sẽ tăng trong khi giá không đổi. Khi bạn stake hoặc unstake, bạn không làm thay đổi giá, chỉ khi bạn nhận về phần thưởng thì giá mới tăng.

Giá chỉ có thể tăng lên và điều này có thể dẫn đến lỗi làm tròn khi lượng yocto NEAR và số dư của bạn không bằng nhau. Đó cũng là lý do chúng ta cần có quỹ bảo lãnh 1 nghìn tỷ yocto NEAR. Một điều cần chú ý là khi bạn thực hiện hành động unstake, bạn sẽ không thể rút tiền ngay lập tức từ staking pool về ví của mình mà phải đợi thêm 3 epochs (3 x 12 giờ). Một trường dữ liệu nữa là ‘unstake_available_epoch_height’ sẽ lưu epoch height khi bạn gọi phương thức unstake. Tuy nhiên có một lưu ý: nếu bạn gọi tới phương thức unstake ở block cuối cùng của một epoch, thì việc unstake sẽ được thực hiện ở epoch tiếp theo, tức là bạn sẽ phải đợi 4 epochs thay vì 3 epochs. Lý do cho việc này là blockchain đã record lại epoch ở block ngay phía trước, nhưng transaction stake thực sự lại được xác thực ở block tiếp theo (thuộc về epoch tiếp theo do block phía trước là block cuối cùng của epoch trước đó). Để giải quyết trường hợp này, chúng ta cần khóa số token được unstake trong 4 epochs thay vì 3 epochs. Như vậy là chúng ta đã lược qua các thành phần chính cấu tạo nên một tài khoản. 

Khái niệm về cổ phiếu không phải là mới vì trên Ethereum, phần lớn các nhà cung cấp thanh khoản (liquidity provider) và các nhà tạo lập thị trường tự động (AMM) đều đã sử dụng khái niệm này. Ví dụ bạn nạp tiền vào một pool thanh khoản, bạn sẽ nhận lại một loại token từ pool này thay vì số token thực tế được thể hiện ở trên pool. Khi bạn rút tiền từ pool về, bạn sẽ đốt lượng token này và nhận về lượng token thật sự mà trước đó bạn đã nạp vào. Ý tưởng này rất giống với khái niệm cổ phiếu, vì chúng có giá trị tương ứng và chúng ta có thể gọi chúng với một tên gọi khác. 

Đã có một số khám phá về cách mà chúng ta có thể thực hiện đúng logic của contract staking pool và một trong số đó là giới hạn số lượng tài khoản có thể nạp tiền vào một tài khoản pool. Cuối cùng team chúng tôi cũng đã xây dựng được một mô hình staking đơn giản với độ phức tạp thời gian tính toán là hằng số.

Contract Types

So với lockup contract thì contract này không được cấu trúc tốt lắm, và thực tế thì lockup contract phức tạp hơn contract mà chúng ta đang review nhiều. Các kiểu dữ liệu do lập trình viên tự định nghĩa vẫn được đóng gói luôn ở trong contract. Có khá nhiều kiểu dữ liệu tự định nghĩa trong hợp đồng này, ví dụ như kiểu RewardFeeFraction dưới đây:

Ngoài ra còn có một số kiểu dữ liệu tự định nghĩa khác như Account, hay HumanReadableAccount, kiểu dữ liệu này chỉ được dùng cho view call, không thay đổi state của blockchain nên không được dùng trong logic của contract.

Và bên cạnh những kiểu dữ liệu tự định nghĩa trên, chúng ta còn định nghĩa cross contract call (gọi chéo tới method của contract khác) sử dụng interface bậc cao sau:

Ở trên, chúng ta định nghĩa 2 trait. Nó hoạt động bằng cách sử dụng một macro của near_bindgen là ext_contract (viết tắt của external contract – hợp đồng bên ngoài). Bạn có thể dùng tên gọi ngắn gọn cho external contract (ví dụ như ext_voting) và macro của near_bindgen sẽ tự động sinh ra tương ứng để bạn call sau này. Tiếp theo bạn mô tả giao diện của external contract mà bạn muốn sử dụng. Như ví dụ method vote ở trong trait VoteContract bên trên, bạn có thể gọi phương thức vote của external contract và phương thức này nhận vào một tham số có kiểu dữ liệu boolean là ‘is_vote’. 

Sau khi mô tả interface của external contract xong, bạn có thể tạo một Promise khi bạn cần, và truyền vào một positional argument thay vì truyền vào một biến JSON đã được serialize. Macro của near_bindgen sẽ tự động biến tham số đó thành low-level Promise APIs.

Interface thứ hai mà chúng ta mô tả được dùng để tạo callback tới chính contract của chúng ta, và thường loại interface này sẽ được đặt tên là ‘ext_self’. Bạn sẽ cần kiểu interface này khi muốn tạo một callback hoặc thực hiện tính toán trên kết quả trả về của một async Promise. Phương thức ‘on_stake_action’ có mục đích kiểm tra xem hành động staking đã thành công hay chưa.

Contract File Structure

Contract này được chia làm nhiều module nhỏ.

Như ta thấy ở phần file explorer trong ảnh, contract gồm có module chính là lib.rs và module internal.rs. Trong module internal.rs, chúng ta không sử dụng macro near_bindgen, điều này có nghĩa là các method được định nghĩa trong module internal này sẽ không thể được gọi bởi một ai khác. Những method trong file internal.rs chỉ có thể được gọi nội bộ trong module lib.rs và sẽ không sinh ra file dạng JSON, đồng thời không deserialize state của blockchain. Chúng sẽ hoạt động giống với các method bình thường của ngôn ngữ Rust.

Một cách tổng quan thì contract của chúng ta hoạt động như sau: khi một epoch trôi qua thì validator sẽ nhận được một phần thưởng nhất định.

Một số method quan trọng trong Contract

Phương thức ping có nhiệm vụ phân phối phần thưởng cho các validator và thực hiện restake nếu cần thiết. Đầu tiên nó kiểm tra xem đã trôi qua một epoch hay chưa, nếu như một epoch đã trôi qua thì nó sẽ thực hiện restake vì có thể có sự thay đổi của tổng số token được stake.

Phương thức tiếp theo là deposit:

Phương thức ‘deposit’ là phương thức dạng payable, tức là bạn có thể đính kèm một khoản tiền khi thực hiện call method này. Macro #[payable] giống với decorator payable trong Ethereum, cho phép bạn nhận được tiền chỉ khi method có kèm theo macro này mong đợi điều đó. Nếu như bạn đính kèm một khoản token khi call một method mà method đó không có macro payable thì near_bindgen sẽ báo lỗi. Trong tất cả các method của contract này, chúng ta đều gọi tới method ‘internal_ping’ để đảm bảo rằng đã phân phối phần thưởng trước khi thực hiện một thay đổi logic nào đó. Cấu trúc code mà chúng ta thường gặp trong trường hợp này đó là trước khi thực hiện restake, cần thực hiện một số xử lý logic trước.

Phương thức tiếp theo là ‘deposit_and_stake’. Đây là method kết hợp của hai method phía trên. Đầu tiên, bạn nạp token vào tài khoản mà bạn muốn stake, và bạn cũng muốn thực hiện stake lượng token đó ngay lập tức thay vì phải thực hiện hai transaction. Phương thức này cũng là ‘payable’ vì nó có nhận tiền đính kèm khi call phương thức ‘internal_deposit’.

Phương thức tiếp theo là ‘withdraw_all’. Phương thức này giúp chúng ta rút toàn bộ số token chưa được stake từ account gọi đến nó. Khi bạn tương tác với staking pool, bạn cần tương tác với tài khoản sở hữu số dư. Ở trong phương thức này, tài khoản đó được đại diện bởi predecessor_account_id và nhờ biến toàn cục này, chúng ta kiểm tra được tài khoản và rút token chưa được stake nếu được. Nếu như không rút được token về thì method sẽ báo lỗi, chẳng hạn như trường hợp bạn unstake token nhưng chưa quá 4 epoch thì bạn sẽ không thể rút token về tài khoản của mình.

Phương thức ‘withdraw’ cho phép chúng ta rút một phần số dư về ví thay vì rút toàn bộ như phương thức ‘withdraw_all’.

Phương thức ‘stake_all’ cho phép bạn stake toàn bộ số token của mình. Hiếm khi nào chúng ta gọi tới phương thức này vì chúng ta có thể sử dụng phương thức ‘stake’ bên dưới và nó cho phép chúng ta chọn số lượng token một cách linh hoạt hơn.

Với phương thức ‘stake’ bạn có thể lựa chọn số lượng token mình muốn stake. Ví Moonlight sử dụng một khoản chi phí riêng để stake nhưng họ dùng transaction theo lô để thực hiện việc này.

Tiếp theo là method ‘unstake_all’ giúp bạn unstake tất cả lượng token bạn đã stake bằng cách chuyển chúng sang dạng yocto NEAR. Ở trong method này, chúng ta cũng sử dụng một phương thức hỗ trợ(helper method) chuyển đổi lượng token bạn đã stake sang dạng yocto NEAR và làm tròn xuống giá trị đó. Lý do cần có việc này là vì bạn sẽ không nhận được lượng token bằng kết quả của phép nhân giữa lượng token bạn stake và giá của NEAR. Sau khi tính ra được lượng token, chúng ta sẽ gọi tới phương thức ‘unstake’ ở trên cùng với tham số là số lượng token vừa tìm được.

Phương thức hỗ trợ ‘staked_amount_from_num_shares_rounded_down’ tương tác với kiểu dữ liệu u256 vì các số dư có kiểu dữ liệu u128. Để tránh hiện tượng tràn số thì chúng ta sẽ nhân số lượng cổ phiếu với total_stake_shares sau khi đã ép kiểu sang dạng u256. Kết quả trả về là thương số được làm tròn xuống.

Phiên bản làm tròn lên: staked_amount_from_num_shares_rounded_up cũng tương tự, ngoại trừ việc chúng ta làm tròn lên thương số. Và ở cuối của mỗi method chúng ta đều thực hiện ép kiểu giá trị nhận được về kiểu u128.

Và cuối cùng chúng ta có method ‘unstake’ có chức năng tương tự ‘unstake_all’ chỉ khác là bạn có thể truyền vào tham số là số lượng token bạn muốn unstake. 

Các phương thức Getter/View

Ở phần này chúng ta sẽ phân tích qua các getter method, các method này trả về một số giá trị để chúng ta thực hiện các view call tới smart contract. Với các method này, chúng ta có thể lấy được giá trị của số token đã được stake hoặc chưa được stake của một tài khoản, tổng số dư của một tài khoản, kiểm tra xem có thể rút token về ví không, …

Với những phương thức dưới đây thì bạn cũng có thể lấy được account_id của chủ sở hữu staking pool, phần thưởng và hoa hồng hiện tại của pool, key hiện tại của staking pool hay kiểm tra xem chủ pool có đang dừng pool hay không.

Giả sử, chủ pool thực hiện chuyển staking pool lên một node thì họ cần hoàn tất việc unstake. Họ có thể làm như sau: tạm dừng staking pool – điều này sẽ gửi một transaction làm thay đổi state tới NEAR, sau đó chủ pool sẽ tiếp tục stake khi họ mở lại pool. Nếu chủ pool dừng staking pool lại thì bạn vẫn có thể rút token về ví, chỉ có điều bạn sẽ không nhận được phần thưởng staking nữa.

Phương thức ‘get_account’ ở trên giúp chúng ta lấy ra tên tài khoản ở format mà con người có thể đọc được (ví dụ alex.near), đồng thời bạn cũng lấy được các thông tin như số lượng token bạn đang có tương ứng với số lượng cổ phiếu mà bạn sở hữu ở giá hiện tại, hay thông tin về việc bạn có thể rút số token đó về ví hay không.

Bên cạnh đó còn có các phương thức giúp bạn lấy được thông tin về số lượng delegator (số người ủy quyền) trên staking pool, lấy được một danh sách các tài khoản cùng một lúc thay vì thông tin về chỉ một tài khoản. Ở trong phương thức ‘get_accounts’, chúng ta lấy ra một danh sách các tài khoản bằng cách sử dụng method ‘keys_as_vector’ của kiểu dữ liệu unordered map được định nghĩa trong NEAR-SDK. Phương thức ‘keys_as_vector’ trả về tập hợp các key từ một ánh xạ, sau đó chúng ta có thể dùng một iterator để lặp qua và lấy thông tin các tài khoản từ danh sách các key này. Đây không hẳn là cách làm hiệu quả nhất nhưng nó cho phép bạn thực hiện thao tác pagination(phân trang) trên kiểu dữ liệu unordered map.

Owner Methods

Owner method – phương thức mà chỉ chủ sở hữu của pool mới có thể call. Ví dụ, chủ của pool có thể cập nhật lại key của staking pool khi họ muốn chuyển pool lên một node khác. Các phương thức của chủ pool đều phải kiểm tra xem người gọi tới phương thức có phải chủ pool không trước khi thực hiện các logic khác (self.assert_owner()).

Trên đây là phương thức giúp chủ pool thay đổi hoa hồng của pool. Hoa hồng phần thưởng của pool sau khi được chủ pool cập nhật sẽ được áp dụng ngay lập tức cho epoch hiện tại, nhưng tất cả hoa hồng phần thưởng trước đó sẽ được tính phí khi chưa cập nhật.

Phương thức ‘vote’ bên trên chính là phương thức mà chúng ta đã review ở bài trước trong series, với mục đích bỏ phiếu cho việc chuyển sang giai đoạn tiếp theo của mainnet.

Tiếp theo là hai method mà tôi cũng đã đề cập tới, hai phương thức này cho phép dừng/tiếp tục chạy staking pool.

Phần mã nguồn còn lại là code kiểm thử của contract. Phần lớn logic của hợp đồng này được định nghĩa trong module internal.rs.

Simulation Test

Chúng ta cũng sẽ có một số simulation test cho một pool cụ thể. Ở đây ta mô phỏng lại cách mà mạng lưới sẽ hoạt động. Đầu tiên, chúng ta khởi tạo pool.

Giả sử Bob là người ủy quyền(delegator) cho staking pool. Bob gọi tới phương thức nạp token ‘deposit_amount’, sau đó Bob có thể kiểm tra xem số dư chưa được stake có đúng không, nếu đúng thì Bob có thể stake số lượng token mà anh muốn. Tiếp theo, chúng ta kiểm tra tổng số lượng token được stake và xác nhận lại Bob có stake cùng lượng token đó không.

Bob gọi tới method ping. Trong simulation test thì không có phần thưởng nào cho việc staking nên bạn cần phải thực hiện nó một cách thủ công. Chúng ta sẽ xác nhận lại một lần nữa là tài khoản của Bob không thay đổi, sau đó tiếp tục chạy staking pool. Ở dòng 11, ta xác nhận lại là pool có đang chạy không, sau đó lock về 0. Tiếp theo, chúng ta mô phỏng rằng pool nhận được phần thưởng là 1 NEAR và Bob ping tới pool. Dòng 23, chúng ta kiểm tra xem lượng token mà Bob nhận được có phải số dương không. Đây là một mô phỏng đơn giản để kiểm tra cách mà pool được dừng hoặc được tiếp tục có hoạt động đúng như dự tính không, pool có dừng việc stake lại khi đang bị dừng không. Sau đó ta để pool tiếp tục stake và pool thực sự đã stake token. Vì vậy simulation test này còn kiểm tra được rằng Bob đã thực sự nhận được phần thưởng staking và chức năng phân phối phần thưởng đã hoạt động. Bên cạnh đó còn có một module test nữa, tuy nhiên nó hơi phức tạp. Phía dưới đây là một số unit test để kiểm thử một số logic của contract:

Những unit test này không hẳn là những best practice để viết unit test nhưng chúng đủ tốt để đảm bảo logic của smart contract hoạt động như mong muốn.

internal.rs

Internal Ping Method

Bây giờ chúng ta cùng phân tích method ‘internal_ping’ ở trong module internal.rs.

Đây là method mà ai cũng có thể call nhằm xác nhận phần thưởng staking đã được phân phối. Ngay bây giờ, chúng ta có những staking pool đang hoạt động và có một tài khoản NEAR ping tới pool mỗi 15 phút nhằm đảm bảo rằng pool đã phân phối phần thưởng và phần thưởng đó được hiển thị trong phần số dư.

Đầu tiên, chúng ta kiểm tra block height hiện tại, nếu như block height không thay đổi tức là vẫn trong cùng một epoch thì trả về false, không thực hiện việc restake token. Còn nếu như đã chuyển sang epoch mới, chúng ta sẽ tính lại tổng số dư trong tài khoản. Method ping có thể được gọi khi một số token được gửi thông qua các lá phiếu nạp token và chúng đã là một phần của số dư trong tài khoản. Chúng ta nhận về tổng số lượng token của account bao gồm cả số dư bị lock và không bị lock. Số dư bị khóa là lượng token đã stake để nhận thưởng, còn số dư chưa bị khóa cũng có thể có phần thưởng trong một số trường hợp nếu như bạn giảm bớt lượng token stake của mình, tuy nhiên bạn vẫn nhận được phần thưởng trong 2 epoch tiếp theo. Chúng ta sử dụng macro assert! để kiểm tra xem tổng số dư có tăng lên hay không. Số dư phải tăng lên là một điều kiện mà staking pool yêu cầu phải có. Đã có một số thứ xảy ra trong quá trình testnet khiến cho điều kiện trên không được đáp ứng ví dụ như mọi người có khóa kết nối tới cùng staking pool và khi bạn có nó, bạn tiêu số dư đó để trả phí gas, kết quả là bạn có thể bị giảm số dư mà không nhận được phần thưởng staking. Cuối cùng chúng ta tính ra tổng số phần thưởng mà staking pool nhận được là kết quả của việc lấy tổng số dư hiện tại trừ đi tổng số dư của epoch cũ. Nếu phần thưởng là một số dương, ta sẽ phân phối nó tới các tài khoản đã stake token. Chúng ta cũng sẽ tính phần hoa hồng dành cho chủ pool.

Chúng ta nhân giá trị reward_fee_fraction (hoa hồng) với tổng số phần thưởng nhận được. Việc này được thực hiện ở kiểu dữ liệu u256, tương tự như cách làm tròn xuống ở trên.

‘owner_fee’ là lượng yocto NEAR mà chủ pool giữ cho riêng họ. Biến ‘remaining_reward’ lưu giá trị của lượng phần thưởng sẽ được dùng để tiếp tục stake. Chủ sở hữu pool nhận về phần thưởng ở đơn vị là yocto NEAR chứ không phải dưới dạng cổ phiếu, tuy nhiên tất cả các logic của contract được tính bằng cổ phiếu nên chủ pool sẽ mua cổ phiếu với giá của phần thưởng sau khi phân phối cho những người ủy quyền trong pool. Biến ‘num_shares’ là số lượng cổ phiếu mà chủ pool sẽ nhận được như là một khoản phí cho việc vận hành staking pool. Nếu ‘num_shares’ là số dương, chúng ta sẽ tăng lượng cổ phiếu và lưu lại tài khoản của chủ pool, đồng thời chúng ta cũng tăng số lượng cổ phiếu staked (dòng 36 và 39). Nếu vì một lý do nào đó mà trong quá trình làm tròn xuống, số dư trở thành 0, phần thưởng sẽ rất nhỏ và giá của mỗi cổ phiếu lại lớn và phần thưởng nhận được là 0. Trong trường hợp này, số dư sẽ chỉ chuyển sang giá của mỗi cổ phiếu thay vì bù cho chủ pool. Tiếp theo, chúng ta log ra một số thông tin như epoch hiện tại, phần thưởng nhận được, … (dòng 45 tới dòng 51). Cách duy nhất để chúng ta hiển thị số lượng cổ phiếu ra bên ngoài là sử dụng log(nhật ký). Tiếp theo, nếu chủ pool nhận được phần thưởng, đồng nghĩa với việc có rất nhiều cổ phiếu. Chúng ta đã phân phối tất cả phần thưởng trong thời gian liên tục và chúng ta chỉ cập nhật một tài khoản (tài khoản của chủ pool) để nhận hoa hồng và chỉ khi hoa hồng là số dương.

Internal Stake Method

Phương thức ‘internal_stake’ là nơi chúng ta implement price guarantee fund – quỹ đảm bảo giá. Giả sử người tiền nhiệm (predecessor) – trong trường hợp này được đại diện bởi account_id – muốn stake một lượng token. Số dư thực tế không thuộc kiểu JSON, vì đây là một phương thức nội bộ nên chúng ta không cần kiểu JSON. Ta tính toán có bao nhiêu cổ phiếu được làm tròn xuống được yêu cầu để stake một số tiền nhất định, và đây cũng là số lượng cổ phiếu mà người sở hữu pool sẽ nhận được. Và số lượng cổ phiếu này phải là số dương. Sau đó, chúng ta kiểm tra số tiền mà chủ pool phải trả cho cổ phiếu (một lần nữa được làm tròn xuống). Điều này nhằm đảm bảo rằng khi chủ sở hữu mua cổ phiếu và chuyển đổi chúng trở lại mà không có phần thưởng sẽ không bị 1 yocto NEAR. Cuối cùng, chúng ta kiểm tra rằng tài khoản có đủ để thanh toán cho số tiền được tính và chúng ta giảm số dư chưa thanh toán và tăng số lượng cổ phiếu trong tài khoản. Tiếp theo chúng ta sử dụng phương thức hỗ trợ ‘staked_amount_from_num_shares_rounded_up’ để làm tròn lên số lượng cổ phiếu. 1 xu hay 1 yocto NEAR được thêm vào khi làm tròn lên này tới từ quỹ đảm bảo. Chúng tôi tính phí người dùng ít hơn, nhưng chúng tôi đã đóng góp nhiều hơn vào số tiền 1 nghìn tỷ yocto NEAR này đã chỉ định ban đầu. Sự khác biệt khi làm tròn thường chỉ là 1 yocto NEAR. Tiếp theo chúng ta đúc thêm cổ phiếu mới nhờ total_staked_balance và total_stake_shares. Cuối cùng chúng ta log ra giá trị của hai biến trên và trả về kết quả.

Phương thức ‘internal_unstake’ hoạt động tương tự như ‘internal_stake’.  Bạn cần làm tròn lên số lượng cổ phiếu bạn cần trả. Sau đó, tính toán số tiền bạn nhận được, một lần nữa làm tròn số tiền bạn phải trả cho việc này. Việc làm tròn này cũng được hoàn thành nhờ một quỹ bảo lãnh. Sau đó, giảm cổ phiếu để tăng số lượng và state khi bạn có thể unlock số dư trong khoảng thời gian 4 epoch. Giá trị của ‘unstake_amount’ được làm tròn xuống và nhờ đó chúng ta bỏ ra ít hơn một chút để đảm bảo giá của những người khác cũng tham gia pool. NEAR cũng sẽ bồi thường cho các lỗi làm tròn từ các quỹ mà NEAR phân bổ. 

Trên đây là những gì mà tôi muốn phân tích với các bạn về Staking smart contract. 

Conclusion

Chúng tôi đã cập nhật các ristretto keys trong quá trình thiết kế hợp đồng này và thật ngạc nhiên là chúng tôi cần tính đến điều này. 

Trong STAKE_SHARE_PRICE_GUARANTEE_FUND 1 nghìn tỷ yocto NEAR phải đủ cho 500 tỷ giao dịch, đủ dài cho staking pool để không cần nạp lại vì phần thưởng sẽ được phân phối lại ngay lập tức cho total_stake_balance trong lần ping tiếp theo. Chúng tôi đã dành khá nhiều thời gian và nỗ lực cho hợp đồng này, bởi vì chúng tôi đã thực hiện rất nhiều đánh giá bảo mật bao gồm cả bên trong lẫn bên ngoài, đặc biệt là với việc phát triển quỹ bảo lãnh để thực hiện việc làm tròn. Điều đó thật phức tạp và một số thứ thú vị đã được phát hiện trong quá trình đánh giá như ristretto key. Chúng tôi đã đánh dấu nhật ký thay đổi của hợp đồng này, cũng như trong readme có một loạt nội dung ghi chú trong quá trình phát triển và thử nghiệm trên hệ thống thật, nhưng phiên bản gốc mất khoảng một tuần để viết. Sau đó, chúng tôi đã kiểm tra và cải thiện nó. Sau đó, chúng tôi đã thực hiện một loạt các sửa đổi. Nhóm yêu cầu tạm dừng và tiếp tục, bởi vì nếu không, chủ staking pool sẽ không có khả năng hủy đăng ký nếu node của họ gặp sự cố. Họ sẽ bị tấn công mạng. Về cơ bản, cổ phần đang hoạt động này sẽ yêu cầu xác thực và không chạy mạng. Đây không chỉ là vấn đề của những người tham gia mà còn là vấn đề của chính mạng lưới. Bằng cách đó, chủ sở hữu pool có thể tạm dừng việc staking nếu họ không muốn điều hành pool mà họ di chuyển vào pool và giao tiếp càng nhiều càng tốt trước khi thực hiện điều này. Tiếp theo, chúng tôi cập nhật giao diện bình chọn để phù hợp với hợp đồng bình chọn giai đoạn hai. Chúng tôi đã thêm các view method để có thể truy vấn tài khoản theo cách mà con người có thể đọc được. Cuối cùng, đã có một số cải tiến xung quanh các phương pháp batching, do đó, deposit_and_stake, stake_all, unstake_all  withdraw_all thay vì phải thực hiện một cuộc gọi xem trước, sẽ lấy số tiền và đặt số tiền để gọi tiền đặt cược. Đây là cách chúng tôi đã cải thiện mã nguồn.

Khi bạn stake, không chỉ bạn đặt số tiền, chúng tôi còn đính kèm một Promise sẽ kiểm tra xem việc stake có thành công hay không. Điều này là cần thiết vì hai lý do: nếu bạn đang cố gắng stake bằng một key không hợp lệ (không phải ristretto key cụ thể) thì Promise sẽ thất bại trước khi thực hiện. Nó sẽ không xác thực trước khi gửi và điều đó sẽ giúp bạn không cần phải kiểm tra trong hợp đồng. Nó sẽ hoàn nguyên lần call contract gần nhất và mọi chuyện sẽ tốt đẹp. Chúng tôi cũng đã giới thiệu mức stake tối thiểu trên cấp độ giao thức. Số tiền stake tối thiểu là một phần mười số tiền của last seat (giá ghế cuối cùng) và nếu hợp đồng của bạn cố gắng stake ít hơn mức này thì hành động sẽ thất bại và bạn sẽ không thể gửi Promise. Giả sử bạn muốn bỏ một số tiền và bạn đã giảm số dư của mình xuống dưới một phần mười số tiền đã stake. Hành động stake có thể thất bại, và bạn sẽ không thể hủy bỏ lệnh giao dịch, do bạn cần nó để đảm bảo rằng việc unstake phải xảy ra. Trong trường hợp này, chúng tôi có lệnh callback này để kiểm tra xem hành động stake đã hoàn tất thành công hay chưa. Callback về cơ bản sẽ kiểm tra xem nếu nó không thành công và số dư là số dương, chúng ta cần phải unstake. Vì vậy, nó sẽ gọi method unstake với tham số amount bằng 0 để đảm bảo rằng số dư đã được giải phóng. Bạn có thể rút tiền sau 4 epoch trong quá trình thử nghiệm các hợp đồng này mà chúng tôi đã thực hiện trên testnet bản beta 9 trước khi bảo trì. Hợp đồng đã sẵn sàng vào khoảng mùa hè, vì vậy việc thử nghiệm lần lặp lại này có thể mất khoảng 2-4 tháng do sự phức tạp mà nó liên quan đến việc tương tác với giao thức. Có khá nhiều kinh nghiệm mà chúng tôi tích lũy được từ kỹ thuật pagination đến các method hỗ trợ và kết hợp các logic với nhau. Bây giờ, bạn phải thực hiện stake một lượng token vào lockup contract một cách thủ công, nhưng sẽ thật tuyệt nếu bạn không cần phải suy nghĩ về số yocto NEAR phải trả cho chi phí lưu trữ. Bạn chỉ muốn stake mọi thứ từ khóa của mình, nhưng vì nó đã được triển khai thực tế nên đã quá muộn để nghĩ về điều này. Ngoài ra còn có một số gas được hard code và không thể thay đổi vì chúng đã được lưu trên NEAR blockchain.

Vì vậy, vote không quan trọng nhưng method ON_STAKE_ACTION_GAS yêu cầu bạn phải có một số lượng lớn cho mỗi cổ phần và bạn không thể yêu cầu ít hơn. Các hành động rủi ro đối với mọi contract call này sẽ yêu cầu chúng tai phải có một lượng lớn gas và vấn đề là nó gây lãng phí. Giả sử chúng ta đồng ý về việc đốt tất cả gas, lượng gas này sẽ luôn bị đốt cháy và bị lãng phí cộng với việc nó giới hạn số lượng giao dịch bạn có thể đưa vào một block nếu ta đang hạn chế gas theo case này. 

Có rất nhiều lần lặp lại việc kiểm tra hợp đồng bằng cách sử dụng framework simulation test mà chúng tôi đã cải thiện rất nhiều. Ở trong các bài review sau, khi chúng ta review lockup contract, bạn sẽ thấy mã nguồn của lockup contract có khá nhiều điểm cải thiện so với contract này.

73
Scroll to Top
🕺New partnerships = cool giveaways! 5 lucky winners will get (L)Earners NFTs!🏆
This is default text for notification bar