LapTrinhBlockchain

Chia sẻ kiến thức về Lập Trình Blockchain

Kiến thức lập trình, Lập trình Blockchain, Lập trình Smart Contract

5 điều tôi ước ai đó đã nói với tôi khi học cách lập trình hợp đồng thông minh Smart Contract trên NEAR

5 điều tôi ước ai đó đã nói với tôi khi học cách lập trình hợp đồng thông minh Smart Contract trên NEAR

5 điều tôi ước ai đó đã nói với tôi khi học cách lập trình hợp đồng thông minh Smart Contract trên NEAR

Chia sẻ bài viết
0
(0)

Khi tôi bắt đầu phát triển các ứng dụng trong hệ sinh thái NEAR, tôi không có kinh nghiệm viết các hợp đồng thông minh Smart Contract trước đây. Tua nhanh đến hiện tại và tôi đã cộng tác vào nhiều ứng dụng phi tập trung bao gồm:

Trong mỗi dự án này, và nhờ có nhóm NEAR và cộng đồng tuyệt vời , tôi đã học được một số điều mới. Tôi viết bài này ngay bây giờ để chia sẻ những điểm quan trọng nhất mà tôi học được, với hy vọng sẽ giúp những người mới bắt đầu phát triển ứng dụng của riêng họ.

Khi xây dựng hợp đồng thông minh, có hai tình huống hầu như luôn xảy ra. Đầu tiên là người dùng sẽ gửi tiền cho chúng tôi và chúng tôi sẽ muốn đăng ký họ, và thứ hai là chúng tôi sẽ muốn lấy thông tin từ một hợp đồng khác và thực hiện theo nó. Thậm chí đôi khi, chúng tôi sẽ tìm thấy sự kết hợp của cả hai tình huống. Để đối phó hiệu quả với các tình huống như vậy, điều cần thiết là phải hiểu cách:

  1. Biết người dùng nào đang tương tác với hợp đồng của chúng tôi
  2. Truy vấn thông tin từ các hợp đồng khác
  3. Xử lý lỗi, đặc biệt nếu tiền của người dùng đang bị đe dọa
  4. Hãy lưu ý đến việc xử lý GAS có sẵn
  5. Bảo vệ khỏi các cuộc tấn công thông thường

Đối với tất cả các điểm nhưng thứ hai tôi sẽ thảo luận về chủ đề bằng cách sử dụng mã giả. Điều này là do các vấn đề áp dụng cho các hợp đồng thông minh nói chung chứ không phải các vấn đề trong RUST hoặc AssemblyScript. Bởi vì điều này, tôi thích tóm tắt vấn đề để tập trung nhiều hơn vào các khái niệm, thay vì mất chi tiết trong việc giải thích các triển khai cụ thể. Tuy nhiên, tôi sẽ để lại các liên kết đến các tài liệu tham khảo trong sdk gần về cách triển khai chúng.

Vì vậy, không cần đến hạn nữa, hãy bắt đầu!

Người dùng đó là ai?

Người dùng sẽ tương tác với hợp đồng thông minh của bạn bằng cách gọi các phương pháp khác nhau mà bạn triển khai. Nhiều lần, trong khi viết mã một phương thức, bạn sẽ thấy rằng bạn cần truy cập thông tin mà người dùng đang gọi phương thức đó. Đây là các chức năng predecessor()và signer()Rust docs , AS docs ) rất hữu ích. Tôi nhớ một trong những sai lầm đầu tiên của tôi là nhầm lẫn chúng, vì trong một số trường hợp, chúng đề cập đến cùng một người dùng, trong khi trong một số trường hợp khác, chúng đề cập đến những người khác nhau. Vì điều này tôi muốn nói rõ sự khác biệt của chúng.

Nói về mặt kỹ thuật, signer (người ký hoặc sender người gửi) đề cập đến tài khoản đã ký giao dịch ban đầu xuất phát trong lệnh gọi phương thức hiện tại, trong khi tài khoản predecessor (người tiền nhiệm) là tài khoản đã thực hiện lệnh gọi như vậy . Hãy minh họa nó để làm cho nó rõ ràng hơn:

Hình minh họa một cuộc gọi phức tạp trong chuỗi khối GẦN, hiển thị tại mỗi thời điểm ai là predecessor và signer trong bối cảnh
Hình minh họa một cuộc gọi phức tạp trong chuỗi khối GẦN, hiển thị tại mỗi thời điểm ai là predecessor và signer trong bối cảnh

Trong ví dụ, user.near gọi một phương thức trong poolparty.near, phương thức này dẫn xuất thành một cuộc gọi hợp đồng chéo và một cuộc gọi lại (đây thực sự là cách hoạt động của pool party). Khi mã trong poolparty.deposit_and_stake() thực thi, user.near vừa là predecessor vừa là signer. Tuy nhiên, khi mã validator.deposit() thực thi, chúng ta thấy rằng đối với chúng poolparty.near là predecessoruser.near là signer. Điều này là do poolparty.near đã gọi trực tiếp phương thức, nhưng user.near đã thực hiện cuộc gọi đầu tiên, bắt nguồn từ mã đó đang được thực thi. Cuối cùng, khi lệnh gọi lại trong poolparty.deposit_callback() thực thi, chúng ta thấy rằng, một lần nữa, poolparty.near là predecessor và user.near là signer.

Thực tế là, trong hầu hết các tình huống, bạn sẽ chỉ cần sử dụng predecessor . Tuy nhiên, hãy nhớ rằng khi lệnh gọi lại của bạn thực thi, thông tin mà người dùng đã tương tác với phương thức ban đầu của bạn sẽ bị mất. Điều này là do tiền thân bây giờ là hợp đồng của bạn! Để khôi phục nó, cách tốt nhất là chuyển người dùng làm đối số cho phương thức gọi lại của bạn.

Mặc dù trong hầu hết các tình huống, bạn sẽ chỉ sử dụng predecessornhưng có những tình huống mà việc biết người ký là rất hữu ích . Ví dụ: trong nft-marketplace của Matt, chức năng thêm NFT vào thị trường cần phải đảm bảo rằng:

  1. Chức năng được thực thi trong một hợp đồng chéo
  2. Chuỗi thực thi được khởi tạo bởi chủ sở hữu của NFT!

Mẹo bổ sung: Lưu ý rằng, khi hợp đồng của bạn lên lịch gọi lại cho chính nó, thì hợp đồng tiền nhiệm là hợp đồng của chính bạn. Điều này có thể được sử dụng để thực hiện cuộc gọi lại ở chế độ riêng tư . Để làm như vậy, hãy khẳng định rằng người tiền nhiệm là tên hợp đồng của bạn. Điều này có thể đạt được trong Assemblyscript bằng cách thêm một:

assert(context.predecessor == context.contractName, "This is a private function")

dòng đầu tiên của một phương thức, hoặc trong trường hợp rỉ sét, bạn có thể trang trí phương thức bằng một #[private].

Truy vấn thông tin từ một hợp đồng khác

Nhiều lần bạn sẽ muốn truy vấn thông tin từ các hợp đồng khác. Quá trình để làm như vậy là tương tự trong cả Rust và Assemblyscript. Trước tiên, bạn sẽ cần xác định cấu trúc của phản hồi (ý tôi là phản hồi structsẽ quay trở lại), và sau đó xây dựng lại nó trong lệnh gọi lại.

Điều thực sự đang xảy ra trong nền là cả hai hợp đồng đều đang trao đổi đối tượng được tuần tự hóa dưới dạng chuỗi mã hóa JSON và NEAR SDK sẽ giúp bạn lấy lại đối tượng được khởi tạo.

Hãy nêu ví dụ về cách lấy thông tin từ nhóm cổ phần. Trong hai đoạn mã sau, chúng tôi sẽ gọi phương thức get_account() từ trình xác thực, yêu cầu account_id trong hợp đồng của chúng tôi. Kết quả là một cấu trúc với 3 đối số staked_balance:u128:, unstaked_balance:u128 và available:bool.

Tệp cross-contract.ts để nhận thông tin xác thực trong AS:

import { context, env, u128, ContractPromise, ContractPromiseBatch, logging } from "near-sdk-as"

const TGAS: u64 = 1000000000000
const POOL: string = "test-account-1632065640639-6140484"

@nearBindgen
class ArgumentsForPool {
  constructor(public account_id: string) { }
}

@nearBindgen
class PoolInfo {
  constructor(
    public staked_balance: u128,
    public unstaked_balance: u128,
    public available: bool = false
  ) { }
}

export function get_pool_information(): void {
  // Ask information from the pool about **our contract**
  let args = new ArgumentsForPool(context.contractName)

  let promise = ContractPromise.create(
    POOL , "get_account", args.encode(), 5 * TGAS, u128.Zero
  )

  let callbackPromise = promise.then(
    context.contractName, "get_pool_information_callback", "", 5 * TGAS
  )
  callbackPromise.returnAsResult();
}

export function get_pool_information_callback(): bool {
  // Check that callback functions are called by this contract
  assert(context.predecessor == context.contractName, "Just don't")

  // Return the result from the validator
  let info = ContractPromise.getResults()[0]

  if (info.status != 1) {
    // We didn't manage to get information from the pool
    return false
  }

  const pool_information: PoolInfo = decode<PoolInfo>(info.buffer);
  logging.log(pool_information)
  return true
}

Tệp cross-contract.rs để nhận thông tin xác thực trong RUST:

use near_sdk::serde::{Deserialize, Serialize};
use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
use near_sdk::json_types::{U128};
use near_sdk::{env, log, PromiseResult, near_bindgen, ext_contract, Gas, PanicOnDefault, Promise};
near_sdk::setup_alloc!();

const POOL: &str = "test-account-1632065640639-6140484";
const TGAS: Gas = 1_000_000_000_000;
const NO_DEPOSIT: u128 = 0;

#[derive(BorshDeserialize, BorshSerialize, Deserialize, Serialize)]
#[serde(crate = "near_sdk::serde")]
pub struct PoolInfo {
  pub staked_balance: U128,
  pub unstaked_balance: U128,
  pub available: bool
}

// Our contracts interface
#[ext_contract(this_contract)]
trait Callbacks {
  fn get_pool_information_callback(&mut self);  
}

// Pool interface
#[ext_contract(pool_contract)]
trait Pool {
    fn get_account(&self, account_id: AccountId) -> PoolInfo;
}

#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)]
pub struct Contract {}


#[near_bindgen]
impl Contract {

    #[init]
    pub fn new( ) -> Self {
        assert!(!env::state_exists(), "Already initialized");
        Self {}
    }

    pub fn get_pool_information(&mut self) -> Promise {
        pool_contract::get_account(
            env::current_account_id(),
            &POOL,
            NO_DEPOSIT,
            5*TGAS
        ).then(this_contract::get_pool_information_callback(
            &env::predecessor_account_id(),
            NO_DEPOSIT,
            5*TGAS,
        ))
    }

    #[private]
    pub fn get_pool_information_callback(&mut self) -> bool {
      if env::promise_results_count() != 1 {
        log!("Expected a result on the callback");
        return false;
      }

      // Get response, return false if failed
      let pool_info: PoolInfo = match env::promise_result(0) {
          PromiseResult::Successful(value) => near_sdk::serde_json::from_slice::<PoolInfo>(&value).unwrap(),
          _ => { log!("Getting info from Pool Party failed"); return false; },
      };

      log!("{}", pool_info.available);
      return true
    }
}

Tiền đính kèm và xử lý lỗi trong các cuộc gọi lại

Nếu người dùng gọi một phương thức trong hợp đồng của bạn với số tiền đính kèm và hợp đồng của bạn đặt ra một ngoại lệ (tức là nó không thành công) , tiền sẽ tự động quay trở lại người dùng. Đây là một tính năng rất hay của hợp đồng thông minh, đảm bảo người dùng sẽ nhận lại được tiền của họ nếu có sự cố xảy ra.

Tuy nhiên, và điều này rất quan trọng, nếu hợp đồng của bạn gửi một promise (tức là gọi một hợp đồng khác ) và  promise không thành công thì tiền KHÔNG tự động quay trở lại người dùng Điều này là do sau khi phương thức của bạn kết thúc, tiền vẫn còn trong hợp đồng của bạn và bất kỳ lệnh gọi nào mà hợp đồng của bạn thực hiện sẽ được thực hiện sau đó một cách không đồng bộ. Ngay cả khi bạn đính kèm một khoản tiền đặt cọc vào lời hứa, tiền sẽ quay trở lại hợp đồng của bạn chứ không phải người dùng . Do đó, nhiệm vụ của chúng tôi là kiểm tra xem mã có được thực thi chính xác hay không và gửi lại tiền cho người dùng nếu không.

Hãy minh họa điều này:

Nếu số tiền đính kèm của bạn vào một promise, hãy đảm bảo xử lý chính xác số tiền đó trong khi gọi lại

Trong ví dụ của chúng tôi, một người dùng gọi phương thức của chúng tôi money_and_stake() đính kèm một số tiền vào nó. Sau đó, phương thức của chúng tôi tạo ra một promise đặt số tiền đó vào trình xác thực và một lệnh gọi lại cho hàm money_callback() . Nếu, vì bất kỳ lý do gì, phương thức của chúng tôi sinh ra lỗi ngoại lệ trong quá trình thực hiện, tiền sẽ tự động được trả lại cho người dùng. Tuy nhiên, sau khi promise và lệnh gọi lại được tạo, nó được coi là hàm secure_and_stake() đã hoàn thành chính xác và do đó, tiền bây giờ là của chúng tôi để xử lý . Nếu trong cuộc gọi lại, chúng tôi phát hiện ra rằng lệnh gọi tới validator.deposit () không thành công , thì chúng tôi phải trả lại tiền cho người dùng theo cách thủ công, vì trả lại sẽ không được kích hoạt tự động.

Có đủ GAS để xử lý cuộc gọi lại

Mỗi lệnh gọi đến một phương thức được cung cấp một lượng GAS cụ thể để chạy. Về cơ bản, mỗi hoạt động tiêu tốn một lượng GAS nhất định. Nếu chúng ta dùng hết GAS, quá trình tính toán sẽ bị gián đoạn và một ngoại lệ được đưa ra. Điều này được thực hiện để các chức năng không thể tốn một lượng tiền vô hạn để chạy.

Khi phát triển một lệnh gọi lại, điều rất quan trọng là phải lưu ý đến lượng GAS cần thiết để thực thi nó. Điều này là do, sau khi chức năng chính của chúng tôi kết thúc chính xác, việc gọi lại vẫn có thể không thành công do thiếu GAS . Điều này có thể rất đáng tiếc, đặc biệt nếu chúng tôi đang sử dụng lệnh gọi lại của mình để hoàn nguyên trạng thái hợp đồng của chúng tôi trong trường hợp có lỗi. Ví dụ: trong ví dụ trước của chúng tôi khi chúng tôi trả lại tiền cho người dùng, nếu chúng tôi hết GAS, số tiền đó sẽ không được trả lại !.

Để đảm bảo rằng một hàm và lệnh gọi lại của nó có đủ gas, bạn có thể thêm một xác nhận vào những dòng đầu tiên của nó. Giả sử bạn ước tính hàm của mình sẽ tiêu thụ 100 TGAS, bạn có thể làm:

const TGAS: u64 = 1000000000000
assert(context.prepaidGas >= 100 * TGAS, "Not enough GAS")  

Trong Rust:

const TGAS: u64 = 1_000_000_000_000;
assert!(env::prepaid_gas() >= 100 * TGAS, "Not enough GAS"); 

Mẹo bổ sung : Bạn ước tính GAS cần thiết cho chức năng như thế nào? Bạn có thể chạy nó một lần và xem nó đã tiêu thụ bao nhiêu GAS trong NEAR explorer.

Hãy lưu ý đến các cuộc gọi lại không đồng bộ (Reentrancy Attacks)

Khi bạn tạo các lời hứa và lệnh gọi lại trong mã của mình, điều quan trọng là phải hiểu rằng chúng sẽ được thực thi không đồng bộ và độc lập. Điều này có nghĩa là, khi bạn tạo một Promise và mã của bạn hoàn tất, bạn phải giả định rằng lệnh gọi lại sẽ không được thực thi ngay lập tức. Hơn nữa, giả sử rằng bất kỳ phương thức nào khác trong hợp đồng của bạn có thể được thực thi giữa lần gọi đầu tiên và lần gọi lại. Không tính đến kịch bản này là một trong những nguồn gốc của các vụ hack hợp đồng thông minh trong lịch sử. Nó phổ biến đến mức nó có tên riêng: các cuộc tấn công reentrancy. Hãy để tôi minh họa vấn đề này với hai tình huống.

Trong kịch bản đầu tiên của chúng tôi, hãy tưởng tượng một phương thức rút tiền, cho phép người dùng lấy lại một số tiền mà họ đã gửi vào hợp đồng thông minh của chúng tôi. Đây thực sự là một chức năng khác của tiệc bể bơi. Cách tiếp cận sai sẽ là: (1) kiểm tra xem người dùng có đủ số dư hay không, (2) gửi tiền cho người dùng nếu có, (3) đợi cho đến khi có cuộc gọi lại để đặt số dư mới. Điều này là do, giữa các điểm (2) và (3), người dùng có thể lên lịch một cuộc gọi khác để rút và nhận được nhiều tiền hơn! Nhìn vào hình ảnh sau:

Ví dụ và giải thích về cuộc tấn công không đồng bộ (reentrancy) trong hệ sinh thái NEAR

Một lần nữa, và điều quan trọng là phải lặp lại nó. Thời gian từ khi thực thi một phương thức đến khi thực hiện lệnh gọi lại của nó là đủ để các phương thức khác có thể được gọi ở giữa. Do đó, bạn cần phải đảm bảo rằng trạng thái của hợp đồng luôn an toàn. Điều này bao gồm các cuộc gọi giữa các cuộc gọi lại! May mắn thay, giải pháp cho vấn đề này khá đơn giản: cập nhật số dư của người dùng trước khi xếp hàng chuyển khoản và gọi lại . Trong cuộc gọi lại, bạn có thể kiểm tra xem có sự cố nào xảy ra hay không và hoàn nguyên số dư của người dùng nếu cần.

Ví dụ và giải thích về cách bảo vệ khỏi cuộc tấn công không đồng bộ (reentrancy) trong hệ sinh thái GẦN

Một điều quan trọng là bạn cần phải hết sức cẩn thận khi gặp cùng một vấn đề này bằng cách kết hợp các phương pháp khác nhau. Ví dụ: hãy sử dụng chức năng rút tiền an toàn kết hợp với chức năng ký quỹ không an toàn.

Hình ảnh cho thấy chúng tôi phát triển ký quỹ_and_stake với logic sai sau: (1) Người dùng gửi tiền cho chúng tôi, (2) chúng tôi thêm tiền vào số dư của nó, (3) chúng tôi cố gắng đặt cược vào trình xác thực, (4) nếu việc đặt cược không thành công , chúng tôi xóa số dư trong lệnh gọi lại. Điều có thể xảy ra là người dùng có thể lên lịch cuộc gọi rút tiền giữa (2) và (4) và nếu việc đặt cược không thành công, chúng tôi sẽ gửi tiền hai lần cho người dùng. Xem hình minh họa sau:

Ví dụ về một cuộc tấn công không đồng bộ qua hai phương pháp trong hệ sinh thái GẦN

May mắn cho chúng tôi, như với ví dụ trước của chúng tôi, giải pháp khá đơn giản. Thay vì ngay lập tức thêm tiền vào số dư của người dùng của chúng tôi, chúng tôi đợi cho đến khi gọi lại. Ở đó chúng tôi kiểm tra và nếu việc đặt cược diễn ra tốt đẹp, thì chúng tôi sẽ thêm nó vào số dư của họ.

Ví dụ và giải thích về cách bảo vệ khỏi cuộc tấn công không đồng bộ (reentrancy) trong hệ sinh thái GẦN

Nếu bạn muốn tìm hiểu thêm về loại tấn công này và chơi xung quanh một chút, bạn có thể kiểm tra công cụ kiểm tra của tôi để biết các cuộc tấn công gần đây .

Điểm bổ sung: Cộng đồng luôn sẵn sàng trợ giúp

Nếu điều gì đó tôi có thể nói mà không nghi ngờ gì thì đó là cộng đồng đã rất hữu ích trong việc cung cấp cho tôi câu trả lời cho các câu hỏi kỹ thuật của tôi. Chính vì điều đó mà tôi muốn khuyến khích các bạn tiếp cận và tham gia vào cộng đồng. Có rất nhiều người rất khéo léo và chuẩn bị kỹ thuật sẵn sàng trả lời các câu hỏi của bạn. Thậm chí, nhiều hơn nữa, nhóm phát triển thường xuyên có giờ làm việc để bạn có thể dễ dàng tham gia và đặt câu hỏi cho họ. Tôi biết vì tôi đã làm điều đó nhiều lần.

Hy vọng bạn có thể thấy bài viết của tôi hữu ích và nó giúp bạn tránh được một số cạm bẫy phổ biến mà chúng ta đều gặp phải trong quá trình học tập của mình. Và bây giờ bạn biết đấy, đừng ngần ngại hỏi, tất cả chúng ta đều đã ở đó, và đó là cách duy nhất để học.

Nguồn: 5 Things I Wish Someone Had Told Me While Learning To Make Smart Contracts

Bài viết này có hữu ích với bạn?

Kích vào một biểu tượng ngôi sao để đánh giá bài viết!

Xếp hạng trung bình 0 / 5. Số phiếu: 0

Bài viết chưa có đánh giá! Hãy là người đầu tiên đánh giá bài viết này.

Trả lời

Giao diện bởi Anders Norén