สร้าง ERC-20 Token ด้วย ink! Part 1/2

Published on
Substrate
ink-erc20-smart-contract
Discord

วันนี้มาลองสร้าง ERC-20 token ด้วย ink! ครับ ซึ่งถ้าหากใครไม่เคยเขียน ink! มาก่อน หรือภาษา Rust มาก่อน แนะนำอ่านบทความก่อนหน้านี้ที่ผมได้เขียนไว้ครับ ทดลองเขียน Smart Contract ด้วย ink! + Rust + Substrate

ซึ่งตัว ERC-20 Token หลักๆ ก็จะเหมือนกับ ERC-20 ของฝั่ง EVM นั่นแหละครับ โดยเนื้อหา Tutorial ผมอ้างอิงจาก Build an ERC-20 token contract

เนื้อหามี 2 ตอนนะครับ

เตรียมตัวกันก่อน

สำหรับใครที่เพิ่งมาอ่านบทความนี้ ก็ลองเตรียมความพร้อม Rust และ Toolchain รวมถึง Substrate Contract Node จากบทความนี้ได้ครับ เตรียมเครื่องมือสำหรับ Substrate Development และควรมีพื้นฐาน Rust และ ink! เบื้องต้นครับ แนะนำอ่านบทความก่อนหน้าของผมได้ครับ สำหรับคนที่ยังไม่เคยเขียน ink!

ERC-20 Standard

เราจะอ้างอิงตัว ERC-20 นี้นะครับ ซึ่งเป็น standard ที่ใช้มากที่สุดใน Ethereum blockchain หน้าตาคร่าวๆ ของมันก็มีดังนี้

contract ERC20Interface {
    // Storage Getters
    function totalSupply() public view returns (uint);
    function balanceOf(address tokenOwner) public view returns (uint balance);
    function allowance(address tokenOwner, address spender) public view returns (uint remaining);

    // Public Functions
    function transfer(address to, uint tokens) public returns (bool success);
    function approve(address spender, uint tokens) public returns (bool success);
    function transferFrom(address from, address to, uint tokens) public returns (bool success);

    // Contract Events
    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

หลักๆ ก็คือ เป็น Interace ที่บอกให้รู้ไว้ว่า Token มีความสามารถอะไรบ้าง เช่น

  • totalSupply - แสดง totalSupply ได้
  • balanceOf - เพื่อดู balance ของ address นั้นๆ
  • transfer, trasferFrom - สำหรับ ส่ง token ระหว่าง address เป็นต้น

จะเห็นว่า Interface ด้านบนเป็น Solidity แต่ว่าสำหรับ ink! ที่เขียนด้วย Rust ตัว return ทีเป็น bool ใน Rust ก็จะใช้ Result แทนนะครับ ส่วนที่เหลือ ก็แทบจะเหมือนๆกัน

Step 1 - Create new contract

สร้างโปรเจ็คขึ้นมา โดยใช้ Cargo Contract

cargo contract new erc20

จะได้โฟลเดอร์ชื่อ erc20 เปลี่ยนข้างในไฟล์ lib.rs เป็น ด้านล่าง

lib.rs
#![cfg_attr(not(feature = "std"), no_std)]

use ink_lang as ink;

#[ink::contract]
mod erc20 {
    use ink_storage::{
        traits::SpreadAllocate,
        Mapping,
    };

    /// Create storage for a simple ERC-20 contract.
    #[ink(storage)]
    #[derive(SpreadAllocate)]
    pub struct Erc20 {
        /// Total token supply.
        total_supply: Balance,
        /// Mapping from owner to number of owned tokens.
        balances: Mapping<AccountId, Balance>,
    }

    impl Erc20 {
        /// Create a new ERC-20 contract with an initial supply.
        #[ink(constructor)]
        pub fn new(initial_supply: Balance) -> Self {
            // Initialize mapping for the contract.
            ink_lang::utils::initialize_contract(|contract| {
                Self::new_init(contract, initial_supply)
            })
        }

        /// Initialize the ERC-20 contract with the specified initial supply.
        fn new_init(&mut self, initial_supply: Balance) {
            let caller = Self::env().caller();
            self.balances.insert(&caller, &initial_supply);
            self.total_supply = initial_supply;
        }

        /// Returns the total token supply.
        #[ink(message)]
        pub fn total_supply(&self) -> Balance {
            self.total_supply
        }

        /// Returns the account balance for the specified `owner`.
        #[ink(message)]
        pub fn balance_of(&self, owner: AccountId) -> Balance {
            self.balances.get(owner).unwrap_or_default()
        }
    }

        #[cfg(test)]
        mod tests {
        use super::*;

        use ink_lang as ink;

        #[ink::test]
        fn new_works() {
            let contract = Erc20::new(777);
            assert_eq!(contract.total_supply(), 777);
        }

        #[ink::test]
        fn balance_works() {
            let contract = Erc20::new(100);
            assert_eq!(contract.total_supply(), 100);
            assert_eq!(contract.balance_of(AccountId::from([0x1; 32])), 100);
            assert_eq!(contract.balance_of(AccountId::from([0x0; 32])), 0);
        }
    }
}

ตัว Source Code จากเว็บ Substrate ink! workshop

ทดสอบ verify ดูว่า test ผ่านมั้ย

cargo +nightly test

รอ cargo compile ซักนิด ถ้าไม่มี dependencies เลย มันก็จะไปดาวน์โหลดและ compiled ครับ ถ้าเทสผ่าน จะได้ผลลัพธ์ประมาณนี้

     Running unittests lib.rs (target/debug/deps/erc20-879749f53b41eace)

running 2 tests
test erc20::tests::new_works ... ok
test erc20::tests::balance_works ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

เมื่อเทสผ่าน ลอง build เป็น WebAssembly contract ดู

cargo +nightly contract build

เมื่อ build เสร็จเราจะได้ไฟล์ erc20.contract ในโฟลเดอร์ target/ink สามารถ นำไฟล์นี้ไป deploy ผ่าน Contract UI ดูได้ครับ

สามารถกลับไปอ่านบทความก่อนหน้านี้ เรื่องของการ Deploy ผ่าน Contracts UI ได้ที่บทความ ทดลองเขียน Smart Contract ด้วย ink! + Rust + Substrate

Step 2 - Transfer tokens

ณ ตอนนี้ ตัว ERC-20 contract ของเรา จะมีแค่ 1 user account ที่เป็นเจ้าของ total_supply ของ token อยู่ วิธีที่จะทำให้ contract สมบูรณ์ ก็คือ ต้องสามารถ transfer tokens ให้ accounts อื่นๆ ได้

Logic ในการ transfer คือ

  1. ดูค่า balance ของ account from และ to
  2. เช็คดูว่าเงินของ from น้อยกว่า value หรือเปล่า? มีเงินแค่ 100 เดียว แต่จะส่ง 1แสน ต้องส่งไม่ได้ :)
  3. หักเงินจาก from และเพิ่มเงินให้ to ด้วยจำนวน value

เพิ่ม Error type ที่บรรทัดที่ 8 ต่อจาก use ink_storage:: ในไฟล์ lib.rs

lib.rs
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
    /// Return if the balance cannot fulfill a request.
    InsufficientBalance,
}

pub type Result<T> = core::result::Result<T, Error>;

เขียน function transfer() เพื่อให้ contract caller สามารถส่ง token ให้ account อื่นได้

วางต่อจากฟังค์ชั่น balance_of() ได้เลย

#[ink(message)]
pub fn transfer(&mut self, to: AccountId, value: Balance) -> Result<()> {
  // เหมือน msg.sender
  let from = self.env().caller();
  self.transfer_from_to(&from, &to, value)
}

fn transfer_from_to(
  &mut self,
  from: &AccountId,
  to: &AccountId,
  value: Balance,
) -> Result<()> {
  let from_balance = self.balance_of_impl(from);

  // 1. เช็คว่า ถ้า balance ของ from น้อยกว่า value ที่จะส่ง ต้องส่งไม่ได้
  if from_balance < value {
    return Err(Error::InsufficientBalance)
  }

  // 2. ลด balance ของ from
  self.balances.insert(from, &(from_balance - value));
  let to_balance = self.balance_of_impl(to);

  // 3. เพิ่ม balance ให้ to
  self.balances.insert(to, &(to_balance + value));
  Ok(())
}

#[inline]
fn balance_of_impl(&self, owner: &AccountId) -> Balance {
  self.balances.get(owner).unwrap_or_default()
}

ลองเทส ดูว่า ว่าผลลัพธ์เป็นยังไง

cargo +nightly test

Step 3 - Events

ต่อมาเราจะสร้าง Event โดยปกติแล้ว ตัว contract ไม่สามารถ return value ตรงๆ ได้ (ต้องรอ confirm) ฉะนั้น วิธีที่จะทำให้ client รู้ได้ ก็คือรู้ผ่าน Event นั่นเอง

Event ใน ink! เราจะกำหนดเป็น strcut ครับ โดยใช้ attribute #[ink(event)]

เพิ่ม struct ต่อจากบรรทัด pub type Result<T> = core::result::Result<T, Error>; ได้เลย

#[ink(event)]
pub struct Transfer {
  #[ink(topic)]
  from: Option<AccountId>,
  #[ink(topic)]
  to: Option<AccountId>,
  value: Balance,
}

การ Emit event เราจะกำหนด ให้ emit 2 ส่วนคือ

  1. ตอนที่ new - เพื่อ initialize contract (ขั้นตอนนี้ไม่มี to ฉะนั้น to จึงเป็น None ได้)
  2. ทุกๆ ครั้งที่เรียก function transfer_from_to

เพิ่ม emit event ที่ function new_init()

lib.rs
fn new_init(&mut self, initial_supply: Balance) {
  let caller = Self::env().caller();
  self.balances.insert(&caller, &initial_supply);
  self.total_supply = initial_supply;
  Self::env().emit_event(Transfer {
      from: None,
      to: Some(caller),
      value: initial_supply,
  });
}

และเพิ่ม emit ใน transfer_from_to() แบบนี้

lib.rs
fn transfer_from_to(
    &mut self,
    from: &AccountId,
    to: &AccountId,
    value: Balance,
) -> Result<()> {
    let from_balance = self.balance_of_impl(from);

    // 1. เช็คว่า ถ้า balance ของ from น้อยกว่า value ที่จะส่ง ต้องส่งไม่ได้
    if from_balance < value {
        return Err(Error::InsufficientBalance);
    }

    // 2. ลด balance ของ from
    self.balances.insert(from, &(from_balance - value));
    let to_balance = self.balance_of_impl(to);

    // 3. เพิ่ม balance ให้ to
    self.balances.insert(to, &(to_balance + value));

    self.env().emit_event(Transfer {
        from: Some(*from),
        to: Some(*to),
        value,
    });
    Ok(())
}

ลองเพิ่มเทส transfer ใน mod tests ดู

lib.rs
#[ink::test]
fn transfer_works() {
    let mut erc20 = Erc20::new(100);
    assert_eq!(erc20.balance_of(AccountId::from([0x0; 32])), 0);
    assert_eq!(erc20.transfer(AccountId::from([0x0; 32]), 10), Ok(()));
    assert_eq!(erc20.balance_of(AccountId::from([0x0; 32])), 10);
}

ทดสอบรันเทสอีกครั้ง

cargo +nightly test

จะมี 3 tests และผ่านหมด แสดงว่า เราทำถูกต้อง

running 3 tests
test erc20::tests::new_works ... ok
test erc20::tests::balance_works ... ok
test erc20::tests::transfer_works ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

🎉 เบื้องต้น ตอนนี้เราก็สามารถทำเหรียญ ERC-20 token โดยใช้ ink! ได้แล้ว แต่ความสามารถของมันก็ยังไม่ได้มีแค่ transfer ต่อไป Part 2 เราจะมาต่อด้วยเรื่องของการ Approve token และการ transfer เหรียญจาก account คนอื่น (ขอสิทธ์แล้ว) ว่าทำยังไงนะครับ

สำหรับตอนนี้ แนะนำว่า ลองทบทวน และลองอ่านโค๊ดดูครับ และพยายามทำความเข้าใจดูว่าโค๊ดมันทำงานยังไง ถ้าอ่านไม่เข้าใจ แนะนำ ลองไปอ่าน Rust Book บท 1-6 เพิ่มเติมครับ รวมถึง ink! Doc เพิ่มเติม เรื่องของ Attributes นะครับ

ไว้มาต่อ Part 2 เร็วๆนี้ครับ

Happy Coding ❤️

References

Buy Me A Coffee
Authors
Discord