니어 프로토콜도 layer-1 블록체인 중 하나다. layer-1은 비트코인이나 이더리움 같이 독립적으로 운영되는 체인이라고 생각하면 된다.
우후죽순 생겨나고 있는 다른 layer-1 체인들과 같이 이더리움의 확장성을 해결하기 위해서 니어 프로토콜만의 해결방법을 제시했다. 이는 지금의 이더리움 체인이 적용시키고 있는 Sharding 기술을 미리 도입해서 TPS(초당 트랜잭션 처리량)을 높인다는 것이었다.
재밌는 것은 Sharding 기술이 니어 프로토콜에서 사용되는 컨트랙트를 작성하는 방법에도 영향을 미친다는 것이다. 이더리움 솔리디티 환경에서도 컨트랙트를 작성할 때 조금이라도 규모가 커지면, 컨트랙트를 분리하게 되고 컨트랙트끼리 상호작용을 하게끔 구현하게 된다. 니어 컨트랙트도 마찬가지로 컨트랙트의 상호작용을 구현할 수 있다.
이더리움의 컨트랙트 상호작용 트랜잭션은 EVM을 통해 실행이 되면 메서드 내 다른 컨트랙트 메서드 호출도 한 큐에 실행되고 블록에 포함된다. 반면에 니어 프로토콜은 Promise라는 비동기 방식을 이용한다. 한 번에 동기식으로 상태값을 변경하는 것이 아니라, 호출한 컨트랙트가 결과를 반환하길 기다리게 된다. 그리고 이러한 이유로 교차 컨트랙트 호출
의 테스트는 Near에서 제공해주는 Workspace
라이브러리를 이용해야 한다.
Near에서 Rust와 js로 컨트랙트를 작성할 수 있게 sdk를 제공해주고 있는데, 필자의 경우는 Rust를 이용하였다.
우선은 니어에서 제공해주는 hello-near 예시를 를 이용하려고 한다.
[ hello-near 코드 ]
use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
use near_sdk::{log, near_bindgen, ext_contract, PanicOnDefault, AccountId, Promise};
use near_sdk::json_types::U128;
// Define the default message
const DEFAULT_MESSAGE: &str = "Hello";
// Define the contract structure
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)]
pub struct Contract {
message: String,
}
// Define the default, which automatically initializes the contract
impl Default for Contract{
fn default() -> Self{
Self{message: DEFAULT_MESSAGE.to_string()}
}
}
// Implement the contract structure
#[near_bindgen]
impl Contract {
// Public method - returns the greeting saved, defaulting to DEFAULT_MESSAGE
pub fn get_greeting(&self) -> String {
return self.message.clone();
}
// Public method - accepts a greeting, such as "howdy", and records it
pub fn set_greeting(&mut self, message: String) {
log!("Saving greeting {}", message);
self.message = message;
}
}
hello-near 코드로 만들어지는 컨트랙트는 message라는 상태값에 간단한 문자열을 저장해둘 수 있는 기능을 제공한다.
그리고 컨트랙트 하나를 새로 만드려고 한다. 컨트랙트 이름은 promise이다.
[ promise 코드 ]
use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
use near_sdk::{near_bindgen, Balance, AccountId};
use near_sdk::json_types::U128;
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct Contract {
balance: Balance,
}
impl Default for Contract{
fn default() -> Self{
Self {
balance: 1000000
}
}
}
#[near_bindgen]
impl Contract {
pub fn get_balance(&self) -> U128{
return self.balance.into()
}
pub fn set_balance(&mut self, _balance: U128) -> U128{
self.balance = _balance.0;
self.balance.into()
}
pub fn extern_set_balance(&mut self, _balance: U128) -> U128{
self.balance = _balance.0;
self.balance.into()
}
}
promise 컨트랙트는 필자가 Balacne
타입과 명칭을 사용하긴 했지만 사실 hellon-near 의 문자열 저장하는 기능을 숫자를 저장하는 기능으로 바꾼 것 뿐이다.
promise 컨트랙트의 extern_set_balance
메서드가 이번에 주로 살펴보게 될 메서드이다. set_balance
와 다를 게 없고 사실 set_balance
를 그대로 이용해도 된다. 하지만 다른 컨트랙트에서 호출하는 메서드를 분리해서 설명하고 싶어서 굳이 따로 만들었다.
내 목표는 hello-near 컨트랙트에서 promise 컨트랙트의 extern_set_balance
메서드를 호출할 수 있게 만드는 것이다. 이런 교차 함수 호출을 만드려면 hello-near를 다음과 같이 변경해야 한다.
[hello-near]
// Find all our documentation at https://docs.near.org
use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
use near_sdk::{log, near_bindgen, ext_contract, PanicOnDefault, AccountId, Promise};
use near_sdk::json_types::U128;
// Define the default message
const DEFAULT_MESSAGE: &str = "Hello";
// Define the contract structure
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)]
pub struct Contract {
message: String,
account_id_promise: AccountId
}
#[ext_contract(promise_contract)]
trait ExtContract{
fn extern_set_balance(&mut self, _balance: U128);
}
// Implement the contract structure
#[near_bindgen]
impl Contract {
#[init]
pub fn init(_message: String, _account_id_promise: AccountId)-> Self {
Self{
message: _message,
account_id_promise: _account_id_promise
}
}
// Public method - returns the greeting saved, defaulting to DEFAULT_MESSAGE
pub fn get_greeting(&self) -> String {
return self.message.clone();
}
// Public method - accepts a greeting, such as "howdy", and records it
pub fn set_greeting(&mut self, message: String) {
log!("Saving greeting {}", message);
self.message = message;
}
pub fn promise_set_balance(&self, _balance: U128) -> Promise {
promise_contract::ext(self.account_id_promise.clone())
.extern_set_balance(_balance.clone())
}
}
보면 ExtContract
trait이 추가되었고, extern_set_balance
가 정의되어 있다. 그리고 컨트랙트 메서드로 promise_set_balance
가 추가된 것도 확인할 수 있다. 또한 이번 같은 경우는 컨트랙트를 생성하자마자 초기화할 수 있는 default 메서드를 없애고, init 메서드를 만들어서 임의의 사용자가 초기화를 시키게 만들었는데, hello-near 내에 promise가 배포된 이후의 promise의 주소를 저장할 필요가 있기 때문이다.
Near에서 교차 컨트랙트 호출을 하기 위해서는 준비해야 될 3가지가 있다.
이번 예시의 경우 1번은 ExtContract trait으로, 2번은 promise_set_balance 메서드로 충족이 되고, 3번은 extern_set_balance 메서드로 충족이 된다. Caller Contract에서 호출하는 외부 컨트랙트 메서드 이름과, 실제로 Callee Contrac에서 호출되는 메서드의 이름은 동일해야 한다.
[ test 코드 ]
use std::{env, fs};
use near_sdk::json_types::{U128};
use near_units::parse_near;
use serde_json::json;
use workspaces::{Account, Contract};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let hellonear_wasm = include_bytes!("../../contract/dist/hello_near.wasm");
let promise_wasm = include_bytes!("../../contract/dist/promise.wasm");
let worker = workspaces::sandbox().await?;
let hello_near = worker.dev_deploy(hellonear_wasm).await?;
let promise_contract = worker.dev_deploy(promise_wasm).await?;
// create accounts
let account = worker.dev_create_account().await?;
let alice = account
.create_subaccount( "alice")
.initial_balance(parse_near!("5 N"))
.transact()
.await?
.into_result()?;
let init_hello_near = alice.call(hello_near.id(), "init")
.args_json(json!({
"_message":"Hello",
"_account_id_promise":promise_contract.id()})
)
.max_gas()
.transact()
.await?
.into_result();
// begin tests
test_set_balance(&alice, &hello_near, &promise_contract).await?;
Ok(())
}
async fn test_set_balance(
user: &Account,
caller_contract: &Contract,
receiver_contract: &Contract
) -> anyhow::Result<()> {
println!(" Working Set Balance");
user.call(caller_contract.id(), "promise_set_balance")
.args_json(json!({"_balance":"5000"}))
.max_gas()
.transact()
.await?
.into_result()?;
println!(" Call promise success");
let balance = receiver_contract
.call("get_balance")
.view()
.await?
.json::<U128>()?;
println!(" View balance success");
// println!("Balance changed: {}", balance.to_string().clone());
assert_eq!(5000, balance.0);
println!(" Passed ✅ Cross Contract test success");
Ok(())
}
Workspace를 뜯어보도록 하자. 우선 기본적으로 hello-near를 생성할 때 제공되는 테스트 코드를 베이스로 사용하였다. 여기서는 tokio::main
이 주된 테스트 코드를 실행시켜주지만, 다른 예시를 보면 tokio::test
를 이용하여 함수 단위로 테스트를 모두 실행시키는 방법도 가능하다는 것을 알 수 있었다. 참고로 tokio는 Rust에서 비동기 명령을 쉽게 사용하기 위한 라이브러리이다.
우선 사용하고자 하는 컨트랙트를 WebAssembly 형태로 컴파일한 파일들이 필요하다. 확장자명은 wasm이다. 필자 같은 경우는 include_bytes!
라는 내장함수로 파일들을 불러와 테스트에 사용하였다.
workspaces::sandbox()
를 통해 테스트를 할 환경을 만든다. 이는 실제 Near의 메인넷이나 테스트넷을 가상으로 만들어 사용한다고 생각하면 된다. 이후 worker_deploy()
를 통해 해당 환경에 불러온 컨트랙트 파일을 넣어 배포한다. 컨트랙트가 배포되어야 사용할 수 있기 때문이다. 이후 alice가 사용하는 계정을 만들어주고, hello-near 컨트랙트의 초기화를 진행해준다.
테스트 작성 자체는 어렵지 않다. 대신 각 컨트랙트의 메서드를 실행시키고 결과를 어떻게 가져오는지를 고려해야 한다.
컨트랙트의 메서드를 호출하는 방법은 1. Account 타입이 호출하는 방법
과 2. Contract 타입이 호출하는 방법
이 있다. 위 예시에서는 두 방법 다 사용해봤다.
[ Account 타입 호출 방법 ]
user.call(caller_contract.id(), "promise_set_balance")
.args_json(json!({"_balance":"5000"}))
.max_gas()
.transact()
.await?
.into_result()?;
Account 타입이 메서드를 호출하게 되면 call 메서드 인자에 값이 2개가 들어가게 된다. 첫 번째는 해당 컨트랙트의 계정 id값이고, 두 번째는 호출하고자 하는 메서드 명이다. 그리고 이어서 args
나 args_json
을 이용해서 호출하는 메서드의 인자로 넣을 값들을 명시할 수 있다. 이후 상태값을 바꾸는 메서드면 transact
메서드를 실행시키고, 상태값이 바뀌지 않는다면 view
메서드를 실행시킨다. await
은 한 컨트랙트 메서드 실행의 결과가 반환될 때까지 기다리게 한다. 이번 같은 경우는 into_result
는 필요가 없는 메서드지만, 혹시 컨트랙트 호출 이후 결과값을 반환받아 변수에 할당할 필요가 있으면 into_result를 이용하여 받을 수도 있다.
await 까지만 작성하고 값을 할당 받으면
is_success
메서드 같이 promise 실행 성공 여부를 다룰 수도 있다.
[ Contract 타입 호출 방법 ]
let balance = receiver_contract
.call("get_balance")
.view()
.await?
.json::<U128>()?;
Contract 타입은 call 메서드 인자를 하나만 받게 된다. 메서드의 이름만 넣으면 된다. 그리고 이번 같은 경우는 스토리지 변경 없이 저장된 값을 확인하기만 하면되기 때문에 view 메서드를 이용한다. json 메서드를 이용해서 이 코드를 통해 실행하는 컨트랙트 메서드가 JSON 타입의 값을 반환활 때 적절하게 Rust에서 받아 사용할 수 있게 변경해준다.
Method Not Found
메서드를 찾지 못했다는 결과를 여러 번 돌려받은 적이 있다. 혹시 본인이 작성했던 컨트랙트가 최근에 수정한 코드로 컴파일 되었는지 확인해 볼 필요가 있다. 필자 같은 경우는 컴파일할 때마다 새로 생성된 파일이 기존의 있던 파일을 덮어쓰기 할 줄 알았는데 아니었다.
Exceeds Gas Usage
기존의 hello_near 컨트랙트에서는 with_static_gas
를 사용해서 교차 컨트랙트 호출을 했는데, 삭제하고 다시 실행하니 문제가 해결되었다.
1 row, 1 column 에 문제가 일어났다는 불친절한 에러
에러가 친절한게 어디 있겠냐마는 이 문제는 호출한 메서드가 돌려주는 값이 near-sdk에서 제공하는 U64나 U128 타입 일 때, 테스트 코드에서 받는 타입도 U64나 U128을 이용해서 받아야 하는 문제다.
Workspace에서 Promise를 반환하는 함수를 View로 실행시키니 에러가 발생했다. Transact로 바꿔주면 문제없이 실행된다.