Skip to main content

Substrate với frontier

· 12 min read

Trong bài viết này mình sẽ hướng dẫn sử dụng frontier với substrate framework. Substrate chain tạo ra sẽ có thể sử dụng EVM client như metamask, web3.js hay ethers.js để:

  • Deploy solidity smart contract và tương tác với các contract này.
  • Tương tác với các pallet của substrate.

Các bạn tham khảo full code ở đây Substrate frontier tutorial.

Khởi tạo project

Chúng ta khởi tạo project bằng cách sử dụng template nằm trong repository của frontier branch polkadot-v0.9.39. Ở thời điểm viết bài này thì phiên bản polkadot-v0.9.39 là bản cập nhật mới nhất cho frontier tương đương với việc sử dụng substrate framework cũng là bản polkadot-v0.9.39 (bản mới hơn của substrate là polkadot-v0.9.40).

Chúng ta chỉ download nội dung nằm trong thư mục template này về máy và đổi tên thành substrate-frontier-template (hoặc tên khác tùy chọn), sau đó copy file Cargo.toml ở ngoài thư mục gốc trên repo vào thư mục hiện tại, file này gọi là workspace Cargo.toml. Sau đó sửa file Cargo.toml này như sau:

  • Update phần frontier dependencies bỏ phần path và thay bằng đường dẫn git đến repo của frontier và branch polkadot-v0.9.39.
# pallet-evm = { version = "6.0.0-dev", path = "frame/evm", default-features = false }
# change to
pallet-evm = { version = "6.0.0-dev", git = "https://github.com/paritytech/frontier", branch = "polkadot-v0.9.39", default-features = false }

Làm tương tự với tất cả các gói frontier khác (bao gồm frontier Client, PrimitiveFrame).

  • Update đường dẫn đến runtime package.
# frontier-template-runtime = { path = "template/runtime", default-features = false }
# change to
frontier-template-runtime = { path = "runtime", default-features = false }

Update workspace members chỉ cần node và runtime.

[workspace]
members = [
"node",
"runtime",
]
resolver = "2"

[workspace.package]
authors = ["Parity Technologies <admin@parity.io>"]
edition = "2021"
repository = "https://github.com/paritytech/frontier/"

Các bạn check thử project có ok không, sau đó có thể build và chạy:

cargo check
cargo build --release

./target/release/frontier-template-node --dev

Ở thời điểm này thì chúng ta đã có 1 substrate blockchain tương thích EVM với các tính năng

  • Deploy solidity contract.
  • Call contract từ metamask hoặc dùng EVM js lib như ethers.
  • Các rpc api cơ bản của EVM như get block number, chain id...

Như vậy về cơ bản nó không khác gì 1 blockchain EVM chạy độc lập. Tuy nhiên trong 1 dự án blockchain sử dụng substrate framework chúng ta có các pallet logic riêng và các pallet này cũng cần tương thích EVM tức là có thể tương tác được với metamask hoặc client lib như Ethers.js

tip

Nếu bạn đã có sẵn 1 substrate blockchain với các pallet logic riêng, có thể apply thêm frontier bằng 1 trong 2 cách:

  1. Dùng frontier template làm gốc:

    • Thêm các pallet vào frontier template, implement các pallet này vào runtime.
    • Thêm custom rpc vào node nếu có.
  2. Dùng project hiện tại làm gốc:

    • Replace node, runtime của project hiện tại thay bằng node, runtime của frontier template.
    • Cài đặt lại các pallet vào runtime, cũng như cài đặt lại vào phần node nếu cần (chain-spec, custom-rpc...).
    • Thêm khai báo các gói phụ thuộc cho frontier vào workspace Cargo.toml (khá nhiều và dài).

Và chú ý version của substrate framework sử dụng phải đồng nhất ở tất cả các package (như trong bài viết này sử dụng bản polkadot-v0.9.39)

tip

Làm cho Pallet tương thích EVM

Thông thường mục đích khi chúng ta tạo 1 blockchain substrate là để phục vụ cho 1 app riêng biệt (trừ 1 số project lớn như moonbeam). Đối với nhu cầu sử dụng của cá nhân mình việc deploy solidity contract trên substrate chain để tạo ra các ứng dụng khác là không cần ở thời điểm hiện tại. Thay vào đó việc làm cho các pallet logic hiện tại có thể tương tác với metamask là quan trọng hơn nhiều vì có thể tiếp cận lượng lớn người dùng metamask (việc phải tạo account polkadot và cài đặt thêm polkadotjs extension là 1 rào cản).

Trong phần này chúng ta sẽ tìm hiểu cách làm cho pallet tương thích EVM. Đầu tiên chúng ta add thêm pallet, mà cụ thể ở đây sử dụng pallet-collectibles mình đã làm ở 1 project sample khác, sửa 1 số dependencies cho tương thích và implement nó vào trong runtime.

Chúng ta cần tạo 1 package gọi là precompile pallet pallet-evm-precompile-collectibles. Pallet này sẽ định nghĩa các method giống như các method được định nghĩa ở trong 1 EVM smart contract, thực hiện nhận các rpc call từ phía client và map sang pallet logic pallet-collectibles đã có sẵn.

Trong ví dụ này mình sử dụng package tiện ích precompile-utils của Moonbeam. Chúng ta sẽ sử dụng 1 macro tiện ích của package này giúp cho việc định nghĩa hàm giống EVM contract cùng các tham số thuận tiện hơn.

Thêm precompile-utils

Tạo folder precompiles ở thư mục gốc. Copy thư mục utils vào trong precompiles. Ở thời điểm bài viết này Moonbeam sử dụng bản tương ứng v0.9.37 nên chúng ta cần vào sửa các gói phụ thuộc cho tương thích, đồng thời các gói reference đến workspace thì phải add thêm vào Cargo.toml ở thư mục gốc (workspace Cargo.toml) nếu thiếu.

Chúng ta check package này xem compile có ok không bằng cách:

  • Thêm vào workspace Cargo.toml
[workspace]
members = [
"node",
"runtime",
"precompiles/utils",
]
  • Chạy lệnh check, nếu gặp lỗi thì chúng ta cần sửa cho đến khi check ok.
cargo check -p precompile-utils
note

Trong trường hợp của mình cần sửa 1 chỗ latest -> v2 file precompiles/utils/src/data/xcm.rs do Moonbeam đang xài bản cũ hơn so với polkadot-v0.9.39.

note

Nếu không sử dụng utils của Moonbeam chúng ta phải xử lý input 1 cách thủ công ở precompile pallet, tham khảo EVM precompiles.

Tạo precompile pallet cho collectibles

Trong precompiles đó tiếp tục tạo thư mục collectibles. Trong thư mục đó

[package]
name = "pallet-evm-precompile-collectibles"
version = "1.0.0-dev"
license = "Apache-2.0"
description = "collectibles precompiles for EVM pallet."
authors = { workspace = true }
edition = { workspace = true }
repository = { workspace = true }

[dependencies]
frame-support = { workspace = true }

# Frontier
fp-evm = { workspace = true }
pallet-evm = { workspace = true }
sp-std = { workspace = true }

precompile-utils = { path = "../utils", default-features = false }
pallet-collectibles = { path = "../../pallets/collectibles", default-features = false }

[features]
default = ["std"]
std = [
"frame-support/std",
"fp-evm/std",
"pallet-evm/std",
"sp-std/std",
"precompile-utils/std",
"pallet-collectibles/std",
]

Sử dụng precompile-utils để khai báo hàm và map sang pallet-collectibles.

  • create_collectible
  • transfer(address, uint128)
#![cfg_attr(not(feature = "std"), no_std)]

use frame_support::{
dispatch::{Dispatchable, GetDispatchInfo, PostDispatchInfo},
};
use fp_evm::{
PrecompileHandle,
};

use sp_std::{marker::PhantomData};
use pallet_evm::AddressMapping;
use precompile_utils::prelude::*;

pub struct CollectiblesPrecompile<Runtime>(PhantomData<Runtime>);

#[precompile_utils::precompile]
impl<Runtime> CollectiblesPrecompile<Runtime>
where
Runtime: pallet_collectibles::Config + pallet_evm::Config,
Runtime::RuntimeCall: Dispatchable<PostInfo = PostDispatchInfo> + GetDispatchInfo,
<Runtime::RuntimeCall as Dispatchable>::RuntimeOrigin: From<Option<Runtime::AccountId>>,
Runtime::RuntimeCall: From<pallet_collectibles::Call<Runtime>>,
{
#[precompile::public("create_collectible()")]
fn create_collectible(handle: &mut impl PrecompileHandle) -> EvmResult {
let origin = Runtime::AddressMapping::into_account_id(handle.context().caller);

let call = pallet_collectibles::Call::<Runtime>::create_collectible {};
// Dispatch call (if enough gas).
RuntimeHelper::<Runtime>::try_dispatch(handle, Some(origin).into(), call)?;

Ok(())
}

#[precompile::public("transfer(address,uint128)")]
fn transfer(
handle: &mut impl PrecompileHandle,
to: Address,
unique_id: u128,
) -> EvmResult {
// get the origin from caller, change from EVM account to substrate account
let origin = Runtime::AddressMapping::into_account_id(handle.context().caller);
// change `to` EVM account to substrate account
let to = Runtime::AddressMapping::into_account_id(to.into());
// change unique_id from u128 to [u8; 16]
let unique_id: [u8; 16] = unique_id.to_be_bytes().into();

// make the call
let call = pallet_collectibles::Call::<Runtime>::transfer {
to,
unique_id
};
// Dispatch call (if enough gas).
RuntimeHelper::<Runtime>::try_dispatch(handle, Some(origin).into(), call)?;

Ok(())
}
}

Ở hàm transfer chúng ta khai báo input dùng kiểu addressuint128 cho EVM interface (phía client JS sẽ truyền lên 2 string hex cho 2 giá trị này, xem ví dụ ở phần test).

note

Macro của Moonbeam precompile-utlis sẽ tự động decode để lấy các giá trị addressuint128 từ handle.

Kiểu address nhận vào là address H160 của EVM (20bytes) sau đó ta chuyển sang kiểu address Substrate 32bytes tương ứng bằng cách sử dụng AddressMapping sẽ khai báo khi config cho EVM pallet ở runtime (mà cụ thể là kiểu HashedAddressMapping<BlakeTwo256>, xem phần config cho EVM pallet).

Tham số thứ 2 chúng ta cần 16 bytes cho unique_id [u8; 16] cho nên ta có thể sử dụng kiểu uint128 (128 bits tương đương 16 bytes), sau đó có thể chuyển thành kiểu [u8; 16] 1 cách dễ dàng.

let unique_id: [u8; 16] = unique_id.to_be_bytes().into();
note

Thêm vào workspace Cargo.toml để check pallet precompile này

[workspace]
members = [
"node",
"runtime",
"precompiles/collectibles",
]

Check và sửa lỗi nếu cần cho đến khi ok.

cargo check -p pallet-evm-precompile-collectibles
note

Chúng ta khai báo package ở members để check package 1 cách độc lập trước khi sử dụng. Khi đã include pallet này vào runtime thì không cần khai báo ở members nữa (hoặc để lại cũng không ảnh hưởng gì).

note

Thêm pallet precompile vào runtime

Khai báo pallet để sử dụng trong runtime

# Local Dependencies
pallet-collectibles = { default-features = false, path = "../pallets/collectibles" }
pallet-template = { version = "4.0.0-dev", default-features = false, path = "../pallets/template" }
pallet-evm-precompile-collectibles = { default-features = false, path = "../precompiles/collectibles" }

[features]
...
std = [
# Local
"pallet-collectibles/std",
"pallet-template/std",
"pallet-evm-precompile-collectibles/std",
]

Gọi pallet trong runtime/src/precompiles.rs

use pallet_evm_precompile_modexp::Modexp;
use pallet_evm_precompile_sha3fips::Sha3FIPS256;
use pallet_evm_precompile_simple::{ECRecover, ECRecoverPublicKey, Identity, Ripemd160, Sha256};

// Custom
use pallet_evm_precompile_collectibles::CollectiblesPrecompile;

Thêm địa chỉ 1026 (mang ý nghĩa như là địa chỉ contract đối với EVM) để gọi đến pallet collectibles:

impl<R> FrontierPrecompiles<R>
where
R: pallet_evm::Config,
{
pub fn new() -> Self {
Self(Default::default())
}
pub fn used_addresses() -> [H160; 8] {
[
hash(1),
hash(2),
hash(3),
hash(4),
hash(5),
hash(1024),
hash(1025),
hash(1026),
]
}
}

Nếu địa chỉ contract request là 1026, gọi chuyển tiếp đến pallet precompiles của collectibles:

impl<R> PrecompileSet for FrontierPrecompiles<R>
where
R: pallet_evm::Config,
CollectiblesPrecompile<R>: Precompile,
{
fn execute(&self, handle: &mut impl PrecompileHandle) -> Option<PrecompileResult> {
match handle.code_address() {
// Ethereum precompiles :
a if a == hash(1) => Some(ECRecover::execute(handle)),
a if a == hash(2) => Some(Sha256::execute(handle)),
a if a == hash(3) => Some(Ripemd160::execute(handle)),
a if a == hash(4) => Some(Identity::execute(handle)),
a if a == hash(5) => Some(Modexp::execute(handle)),
// Non-Frontier specific nor Ethereum precompiles :
a if a == hash(1024) => Some(Sha3FIPS256::execute(handle)),
a if a == hash(1025) => Some(ECRecoverPublicKey::execute(handle)),
// Custom
a if a == hash(1026) => Some(CollectiblesPrecompile::execute(handle)),
_ => None,
}
}

fn is_precompile(&self, address: H160) -> bool {
Self::used_addresses().contains(&address)
}
}

Chúng ta check lại lần cuối và build, và run blockchain:

cargo check -p frontier-template-runtime
cargo check

cargo build --release
./target/release/frontier-template-node --dev

Account setup

Frontier template sử dụng account Ethereum dạng 20 bytes. Chúng ta sửa thành dạng account Multisignature ban đầu của Substrate.

use fp_account::EthereumSignature;
pub type Signature = EthereumSignature;

// => change to

use sp_runtime::MultiSignature;
pub type Signature = MultiSignature;

Sử dụng

  • EnsureAddressTruncated thay cho EnsureAccountId20: kiểm tra việc chuyển đổi giữa account substrate 32bytes thành address ethereum 20bytes có chính xác ko, ví dụ trước khi gọi contract EVM.

  • HashedAddressMapping thay cho IdentityAddressMapping: chuyển đổi address ethereum 20bytes thành account substrate 32bytes. Ví dụ khi gọi đến substrate blockchain từ metamask.

impl pallet_evm::Config for Runtime {
// ...
type CallOrigin = EnsureAddressTruncated;
type WithdrawOrigin = EnsureAddressTruncated;
type AddressMapping = HashedAddressMapping<BlakeTwo256>;
// ...
}

Tham khảo commit.

tip

Chúng ta set pre-fund cho 1 tài khoản ethereum bằng cách set GenegisConfig cho EVM accounts. Tài khoản này sau đó sẽ được sử dụng để test từ ethers.js.

tip
note

Việc account 20bytes gọi từ EVM client (EthersJS, Metamask...) lên Substrate chuyển đổi thành account 32bytes có thể hơi bất tiện (phần storage trên Substrate chúng ta chỉ nhìn thấy account 32bytes). Nên chúng ta có thể cân nhắc sử dụng luôn account 20bytes trên Substrate (tuy nhiên cách này lại k tương thích khi gọi từ polkadot js extension, hay check ở PolkadotJS APP).

note

Test với ethers.js

Chạy blockchain với dev mode

./target/release/frontier-template-node --dev

Tạo thư mục tests, bên trong tạo 1 project typescript đơn giản để test thử blockchain với ethers.js.

Build contract interface để lấy abi interface dạng json.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.8.2 <0.9.0;

interface Collectibles {
function create_collectible() external;
function transfer(address to, uint128 unique_id) external;
}
npx solc contracts/collectibles.sol --abi

Đổi tên file abi thành collectibles.abi.json.

Setup 1 project test đơn giản với jest và typescript (ts-jest), sử dụng Ethers JS để tương tác với Substrate, dùng thêm PolkadotJS để đọc dữ liệu trên Substrate.

// Import the ethers.js library
import { ethers } from 'ethers'
import { apiDisconnect, collectibleCreated, getCollectibleOwner, waitNextBlock } from './utils'

// Test account which holds fund
const USER1_PRIVATE_KEY = '0x99B3C12287537E38C90A9219D4CB074A89A16E9CDB20BF85728EBD97C343E342'
const USER1_ETH_ADDR = '0x6be02d1d3665660d22ff9624b7be0551ee1ac91b'
const USER1_SUBS_ADDR = '5CNJv1vQjABY9W3BtsV2tzaLCjZepWXaYYzuDGWUUNVvMjcG'

// Account2
const USER2_ETH_ADDR = '0xd43593c715fdd31c61141abd04a99fd6822c8558'
const USER2_SUBS_ADDR = '5FrLxJsyJ5x9n2rmxFwosFraxFCKcXZDngRLNectCn64UjtZ'

// Contract address (1026 => 20bytes hex)
const CONTRACT_ADDR = '0x0000000000000000000000000000000000000402'
const CONTRACT_ABI = require('./collectibles.abi.json')

describe('Collectibles frontier', () => {
test('create collectible and transfer', async () => {
// Provider connect to local node
const provider = new ethers.JsonRpcProvider('http://127.0.0.1:9933')
// Signer
const signer = new ethers.Wallet(USER1_PRIVATE_KEY, provider)
// Contract
const contract = new ethers.Contract(CONTRACT_ADDR, CONTRACT_ABI, signer)

// call contract create_collectible method
contract['create_collectible']()
// get unique_id, owner from Substrate System event
const [unique_id, owner] = await collectibleCreated()
// confirm unique_id length, owner
expect(unique_id.length).toBe(34)
expect(owner).toEqual(USER1_SUBS_ADDR)

// call contract transfer to user2 ETH address
await contract['transfer'](USER2_ETH_ADDR, unique_id)

// wait 2 block
await waitNextBlock(2)

// confirm new owner is user2 substrate address
const newOwner = await getCollectibleOwner(unique_id)
expect(newOwner).toEqual(USER2_SUBS_ADDR)

// stop api
await apiDisconnect()
})
})

Chạy thử test cũng như kiểm tra state Blockchain thay đổi.

npm test

Full code test

Full code Substrate frontier tutorial.