[캡스톤디자인B] 비상장주식거래 플랫폼에 필요한 Rust 컨트랙트 작성하기!

cat_dev·2022년 5월 4일
0
post-thumbnail

내가 느낀 블록체인 개발의 가장 큰 장벽은 자세한/정확한 레퍼런스를 찾기 어렵다는 것이었다. 특히 한국어로 적혀있는 튜토리얼은 거의 없다고 보면 되어서, 적응하기 쉽지 않았다.
나는 졸업 프로젝트에서 블록체인 개발을 하며 NEAR 프로토콜의 NEAR University의 튜토리얼과 NEAR discord에 직접 질문하는 방식을 이용하여 막힌 부분들을 풀었다.

이 글이 하나의 좋은 한국어 개발 튜토리얼이 되길 바라며! 내가 사용한 Rust 스마트 컨트랙트 작성과 NEAR 테스트넷에 배포하는 방식을 적어보겠다.

필요 사항

  1. NEAR 테스트넷 계정
  2. near-cli 설치
  3. Rust tool chain 설치

컨트랙트 배포

사용 기술 설명

  1. Cargo 이용
  • rust는 node.js의 npm처럼 cargo라는 관리 툴을 이용한다.
  1. Rust는 어셈블리어인 wasm파일을 만든 후, 이를 블록체인에 배포하는 형식으로 진행한다.
  • rust로 작성된 컨트랙트를 wasm파일로 컴파일 한 후, 원하는 블록체인 네트워크에 배포하는 방식을 이용한다.

절차

STEP 1 레포지토리 만들기

$ cargo new <나의 레포지토리 이름>
$ cd <레포지토리>
  • cargo의 new 명령어를 이용해 cargo 환경이 세팅된 레포지토리를 생성한다.
.
├── Cargo.toml
└── src
   └── main.rs
  • 생성된 레포지토리의 구조는 다음과 같다.
  • src폴더 내부에 .rs 확장자로 되어있는 컨트랙트를 작성하고 배포하는 구조이다.

STEP 2 스마트 컨트랙트 작성하기

스마트 컨트랙트를 계약 내용을 직접 명시하는 것이나 현실의 계약서와 같은 개념이라고 보면 개발할 때 많은 혼란이 있을 것이다. 스마트 컨트랙트는 하나의 인터페이스, 블록체인에 올라가는 데이터 틀을 만든다고 생각하면 더 편하게 이해할 수 있다.

나는 NFT를 발행하고 거래하는 플랫폼을 개발 중이어서 NEAR의 nft표준인 NEP-171 표준을 따르는 스마트 컨트랙트를 작성했다. NEP-171 표준을 사용하여 컨트랙트를 작성하면 해당 표준을 공유하는 NFT간 공유가 가능하다.

  • 내가 만들고 있는 nft 플랫폼의 경우 nft를 발행하는 mint, nft의 개수를 세는 기능인 enumerable, nft 내부 정보를 관리하는 internal, nft에 이미지 등의 정보를 넣을 수 있는 metadata, NEP-171 표준을 명시하는 nft_core의 기능이 필요했다.
  • 많은 기능을 한 컨트랙트에 넣을 수 있지만, 분리하면 frontend와의 상호작용에 매우 편리하므로, 분리해서 넣었다.

핵심기능 1 mint.rs

  • nft를 발행하는 기능이 들어가는 컨트랙트이다.
  • nft_mint 함수를 구현한다.
  • 발행 시 유저가 정한 토큰 아이디와, 메타데이터, 로열티, 받는 사람과 토큰 타입을 저장한다.

전체 코드

use crate::*;

#[near_bindgen]
impl Contract {
    #[payable]
    pub fn nft_mint(
        &mut self,
        token_id: Option<TokenId>,
        metadata: TokenMetadata,
        perpetual_royalties: Option<HashMap<AccountId, u32>>,
        receiver_id: Option<ValidAccountId>,
        token_type: Option<TokenType>,
    ) {

        let mut final_token_id = format!("{}", self.token_metadata_by_id.len() + 1);
        if let Some(token_id) = token_id {
            final_token_id = token_id
        }

        let initial_storage_usage = env::storage_usage();
        let mut owner_id = env::predecessor_account_id();
        if let Some(receiver_id) = receiver_id {
            owner_id = receiver_id.into();
        }

        // CUSTOM - create royalty map
        let mut royalty = HashMap::new();
        let mut total_perpetual = 0;
        // user added perpetual_royalties (percentage paid with every transfer)
        if let Some(perpetual_royalties) = perpetual_royalties {
            assert!(perpetual_royalties.len() < 7, "Cannot add more than 6 perpetual royalty amounts");
            for (account, amount) in perpetual_royalties {
                royalty.insert(account, amount);
                total_perpetual += amount;
            }
        }
        // royalty limit for minter capped at 20%
        assert!(total_perpetual <= MINTER_ROYALTY_CAP, "Perpetual royalties cannot be more than 20%");

        // CUSTOM - enforce minting caps by token_type 
        if token_type.is_some() {
            let token_type = token_type.clone().unwrap();
            let cap = u64::from(*self.supply_cap_by_type.get(&token_type).expect("Token type must have supply cap."));
            let supply = u64::from(self.nft_supply_for_type(&token_type));
            assert!(supply < cap, "Cannot mint anymore of token type.");
            let mut tokens_per_type = self
                .tokens_per_type
                .get(&token_type)
                .unwrap_or_else(|| {
                    UnorderedSet::new(
                        StorageKey::TokensPerTypeInner {
                            token_type_hash: hash_account_id(&token_type),
                        }
                        .try_to_vec()
                        .unwrap(),
                    )
                });
            tokens_per_type.insert(&final_token_id);
            self.tokens_per_type.insert(&token_type, &tokens_per_type);
        }
        // END CUSTOM

        let token = Token {
            owner_id,
            approved_account_ids: Default::default(),
            next_approval_id: 0,
            royalty,
            token_type,
        };
        assert!(
            self.tokens_by_id.insert(&final_token_id, &token).is_none(),
            "Token already exists"
        );
        self.token_metadata_by_id.insert(&final_token_id, &metadata);
        self.internal_add_token_to_owner(&token.owner_id, &final_token_id);

        let new_token_size_in_bytes = env::storage_usage() - initial_storage_usage;
        let required_storage_in_bytes =
            self.extra_storage_in_bytes_per_token + new_token_size_in_bytes;

        refund_deposit(required_storage_in_bytes);
    }
}

핵심기능 2 nft_core.rs

  • NEP-171 표준을 구현한다.
  • nft_transfer, nft_approve, nft_revoke등 nft를 거래하기 위해 필요한 함수를 구현한다.
  • 코어의 경우 커스텀이 필요한 부분이 적어 NEP-171 github를 참고하여 작성하였다.

전체 코드

use crate::*;
use near_sdk::json_types::{ValidAccountId};
use near_sdk::{ext_contract, log, Gas, PromiseResult};

const GAS_FOR_NFT_APPROVE: Gas = 10_000_000_000_000;
const GAS_FOR_RESOLVE_TRANSFER: Gas = 10_000_000_000_000;
const GAS_FOR_NFT_TRANSFER_CALL: Gas = 25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER;
const NO_DEPOSIT: Balance = 0;

pub trait NonFungibleTokenCore {
    fn nft_transfer(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: Option<String>,
    );

  	fn nft_payout(&self, token_id: String, balance: U128, max_len_payout: u32) -> Payout;

    fn nft_transfer_payout(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: String,
        balance: U128,
        max_len_payout: u32,
    ) -> Payout;

    /// Returns `true` if the token was transferred from the sender's account.
    fn nft_transfer_call(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: Option<String>,
        msg: String,
    ) -> PromiseOrValue<bool>;

    fn nft_approve(&mut self, token_id: TokenId, account_id: ValidAccountId, msg: Option<String>);

	fn nft_is_approved(
        &self,
        token_id: TokenId,
        approved_account_id: AccountId,
        approval_id: Option<u64>,
    ) -> bool;

    fn nft_revoke(&mut self, token_id: TokenId, account_id: ValidAccountId);

    fn nft_revoke_all(&mut self, token_id: TokenId);

    fn nft_total_supply(&self) -> U128;

    fn nft_token(&self, token_id: TokenId) -> Option<JsonToken>;
}

#[ext_contract(ext_non_fungible_token_receiver)]
trait NonFungibleTokenReceiver {
    /// Returns `true` if the token should be returned back to the sender.
    fn nft_on_transfer(
        &mut self,
        sender_id: AccountId,
        previous_owner_id: AccountId,
        token_id: TokenId,
        msg: String,
    ) -> Promise;
}

#[ext_contract(ext_non_fungible_approval_receiver)]
trait NonFungibleTokenApprovalsReceiver {
    fn nft_on_approve(
        &mut self,
        token_id: TokenId,
        owner_id: AccountId,
        approval_id: u64,
        msg: String,
    );
}

// TODO: create nft_on_revoke

#[ext_contract(ext_self)]
trait NonFungibleTokenResolver {
    fn nft_resolve_transfer(
        &mut self,
        owner_id: AccountId,
        receiver_id: AccountId,
        token_id: TokenId,
        approved_account_ids: HashMap<AccountId, u64>,
    ) -> bool;
}

trait NonFungibleTokenResolver {
    fn nft_resolve_transfer(
        &mut self,
        owner_id: AccountId,
        receiver_id: AccountId,
        token_id: TokenId,
        approved_account_ids: HashMap<AccountId, u64>,
    ) -> bool;
}

#[near_bindgen]
impl NonFungibleTokenCore for Contract {

    #[payable]
    fn nft_transfer(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: Option<String>,
    ) {
        assert_one_yocto();
        let sender_id = env::predecessor_account_id();
        let previous_token = self.internal_transfer(
            &sender_id,
            receiver_id.as_ref(),
            &token_id,
            Some(approval_id),
            memo,
        );
        refund_approved_account_ids(
            previous_token.owner_id.clone(),
            &previous_token.approved_account_ids,
        );
    }

    fn nft_payout(&self, token_id: String, balance: U128, max_len_payout: u32) -> Payout {
		let token = self.tokens_by_id.get(&token_id).expect("No token");

        // compute payouts based on balance option
        // adds in contract_royalty and computes previous owner royalty from remainder
        let owner_id = token.owner_id;
        let mut total_perpetual = 0;
        let balance_u128 = u128::from(balance);
		let mut payout: Payout = HashMap::new();
		let royalty = token.royalty;

		assert!(royalty.len() as u32 <= max_len_payout, "Market cannot payout to that many receivers");

		for (k, v) in royalty.iter() {
			let key = k.clone();
			if key != owner_id {
				payout.insert(key, royalty_to_payout(*v, balance_u128));
				total_perpetual += *v;
			}
		}
		
		// payout to contract owner - may be previous token owner, they get remainder of balance
		if self.contract_royalty > 0 && self.owner_id != owner_id {
			payout.insert(self.owner_id.clone(), royalty_to_payout(self.contract_royalty, balance_u128));
			total_perpetual += self.contract_royalty;
		}
		assert!(total_perpetual <= MINTER_ROYALTY_CAP + CONTRACT_ROYALTY_CAP, "Royalties should not be more than caps");
		// payout to previous owner
		payout.insert(owner_id, royalty_to_payout(10000 - total_perpetual, balance_u128));

		payout
	}

    #[payable]
    fn nft_transfer_payout(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: String,
        balance: U128,
        max_len_payout: u32,
    ) -> Payout {
        assert_one_yocto();
        let sender_id = env::predecessor_account_id();
        let previous_token = self.internal_transfer(
            &sender_id,
            receiver_id.as_ref(),
            &token_id,
            Some(approval_id),
            Some(memo),
        );
        refund_approved_account_ids(
            previous_token.owner_id.clone(),
            &previous_token.approved_account_ids,
        );

        // compute payouts based on balance option
        // adds in contract_royalty and computes previous owner royalty from remainder
        let owner_id = previous_token.owner_id;
        let mut total_perpetual = 0;
        let balance_u128 = u128::from(balance);
		let mut payout: Payout = HashMap::new();
		let royalty = self.tokens_by_id.get(&token_id).expect("No token").royalty;

		assert!(royalty.len() as u32 <= max_len_payout, "Market cannot payout to that many receivers");

		for (k, v) in royalty.iter() {
			let key = k.clone();
			if key != owner_id {
				payout.insert(key, royalty_to_payout(*v, balance_u128));
				total_perpetual += *v;
			}
		}
		
		// payout to contract owner - may be previous token owner, they get remainder of balance
		if self.contract_royalty > 0 && self.owner_id != owner_id {
			payout.insert(self.owner_id.clone(), royalty_to_payout(self.contract_royalty, balance_u128));
			total_perpetual += self.contract_royalty;
		}
		assert!(total_perpetual <= MINTER_ROYALTY_CAP + CONTRACT_ROYALTY_CAP, "Royalties should not be more than caps");
		// payout to previous owner
		payout.insert(owner_id, royalty_to_payout(10000 - total_perpetual, balance_u128));

		payout
    }

    #[payable]
    fn nft_transfer_call(
        &mut self,
        receiver_id: ValidAccountId,
        token_id: TokenId,
        approval_id: u64,
        memo: Option<String>,
        msg: String,
    ) -> PromiseOrValue<bool> {
        assert_one_yocto();
        let sender_id = env::predecessor_account_id();
        let previous_token = self.internal_transfer(
            &sender_id,
            receiver_id.as_ref(),
            &token_id,
            Some(approval_id),
            memo,
        );
        // Initiating receiver's call and the callback
        ext_non_fungible_token_receiver::nft_on_transfer(
            sender_id,
            previous_token.owner_id.clone(),
            token_id.clone(),
            msg,
            receiver_id.as_ref(),
            NO_DEPOSIT,
            env::prepaid_gas() - GAS_FOR_NFT_TRANSFER_CALL,
        )
        .then(ext_self::nft_resolve_transfer(
            previous_token.owner_id,
            receiver_id.into(),
            token_id,
            previous_token.approved_account_ids,
            &env::current_account_id(),
            NO_DEPOSIT,
            GAS_FOR_RESOLVE_TRANSFER,
        )).into()
    }

    #[payable]
    fn nft_approve(&mut self, token_id: TokenId, account_id: ValidAccountId, msg: Option<String>) {
        assert_at_least_one_yocto();
        let account_id: AccountId = account_id.into();

        let mut token = self.tokens_by_id.get(&token_id).expect("No token");

        assert_eq!(
            &env::predecessor_account_id(),
            &token.owner_id,
            "Predecessor must be the token owner."
        );

        let approval_id: u64 = token.next_approval_id;
        let is_new_approval = token
            .approved_account_ids
            .insert(account_id.clone(), approval_id)
            .is_none();

        let storage_used = if is_new_approval {
            bytes_for_approved_account_id(&account_id)
        } else {
            0
        };

        token.next_approval_id += 1;
        self.tokens_by_id.insert(&token_id, &token);

        refund_deposit(storage_used);

        if let Some(msg) = msg {
            
            // CUSTOM - add token_type to msg
            let mut final_msg = msg;
            let token_type = token.token_type;
            if let Some(token_type) = token_type {
                final_msg.insert_str(final_msg.len() - 1, &format!(",\"token_type\":\"{}\"", token_type));
            }

            ext_non_fungible_approval_receiver::nft_on_approve(
                token_id,
                token.owner_id,
                approval_id,
                final_msg,
                &account_id,
                NO_DEPOSIT,
                env::prepaid_gas() - GAS_FOR_NFT_APPROVE,
            )
            .as_return(); // Returning this promise
        }
    }

	fn nft_is_approved(
        &self,
        token_id: TokenId,
        approved_account_id: AccountId,
        approval_id: Option<u64>,
    ) -> bool {
        let token = self.tokens_by_id.get(&token_id).expect("No token");
		let approval = token.approved_account_ids.get(&approved_account_id);
		if let Some(approval) = approval {
			if let Some(approval_id) = approval_id {
				approval_id == *approval
			} else {
				false
			}
		} else {
			false
		}
    }

    #[payable]
    fn nft_revoke(&mut self, token_id: TokenId, account_id: ValidAccountId) {
        assert_one_yocto();
        let mut token = self.tokens_by_id.get(&token_id).expect("No token");
        let predecessor_account_id = env::predecessor_account_id();
        assert_eq!(&predecessor_account_id, &token.owner_id);
        if token
            .approved_account_ids
            .remove(account_id.as_ref())
            .is_some()
        {
            refund_approved_account_ids_iter(predecessor_account_id, [account_id.into()].iter());
            self.tokens_by_id.insert(&token_id, &token);
        }
    }

    #[payable]
    fn nft_revoke_all(&mut self, token_id: TokenId) {
        assert_one_yocto();
        let mut token = self.tokens_by_id.get(&token_id).expect("No token");
        let predecessor_account_id = env::predecessor_account_id();
        assert_eq!(&predecessor_account_id, &token.owner_id);
        if !token.approved_account_ids.is_empty() {
            refund_approved_account_ids(predecessor_account_id, &token.approved_account_ids);
            token.approved_account_ids.clear();
            self.tokens_by_id.insert(&token_id, &token);
        }
    }

    fn nft_total_supply(&self) -> U128 {
        U128(self.token_metadata_by_id.len() as u128)
    }

    fn nft_token(&self, token_id: TokenId) -> Option<JsonToken> {
        if let Some(token) = self.tokens_by_id.get(&token_id) {
            let metadata = self.token_metadata_by_id.get(&token_id).unwrap();
            Some(JsonToken {
                token_id,
                owner_id: token.owner_id,
                metadata,
                royalty: token.royalty,
                approved_account_ids: token.approved_account_ids,
                token_type: token.token_type,
            })
        } else {
            None
        }
    }
}

#[near_bindgen]
impl NonFungibleTokenResolver for Contract {
    #[private]
    fn nft_resolve_transfer(
        &mut self,
        owner_id: AccountId,
        receiver_id: AccountId,
        token_id: TokenId,
        approved_account_ids: HashMap<AccountId, u64>,
    ) -> bool {
        // Whether receiver wants to return token back to the sender, based on `nft_on_transfer`
        // call result.
        if let PromiseResult::Successful(value) = env::promise_result(0) {
            if let Ok(return_token) = near_sdk::serde_json::from_slice::<bool>(&value) {
                if !return_token {
                    // Token was successfully received.
                    refund_approved_account_ids(owner_id, &approved_account_ids);
                    return true;
                }
            }
        }

        let mut token = if let Some(token) = self.tokens_by_id.get(&token_id) {
            if token.owner_id != receiver_id {
                // The token is not owner by the receiver anymore. Can't return it.
                refund_approved_account_ids(owner_id, &approved_account_ids);
                return true;
            }
            token
        } else {
            // The token was burned and doesn't exist anymore.
            refund_approved_account_ids(owner_id, &approved_account_ids);
            return true;
        };

        log!("Return {} from @{} to @{}", token_id, receiver_id, owner_id);

        self.internal_remove_token_from_owner(&receiver_id, &token_id);
        self.internal_add_token_to_owner(&owner_id, &token_id);
        token.owner_id = owner_id;
        refund_approved_account_ids(receiver_id, &token.approved_account_ids);
        token.approved_account_ids = approved_account_ids;
        self.tokens_by_id.insert(&token_id, &token);

        false
    }
}

핵심기능 3 metadata.rs

  • 발행하고자 하는 nft의 특징이 가장 잘 드러나는 부분이 metadata이다.
  • 토큰의 필요한 정보들을 저장한다.
  • 나의 플랫폼에 필요한 메타 데이터는 제목, 설명, 그림, 그림해시, nft 총 발행 개수, 발행처, 체인의 블록 생성 주기, 레퍼런스 URL이었다.

전체 코드

use crate::*;

#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone)]
#[serde(crate = "near_sdk::serde")]
pub struct NFTMetadata {
    pub spec: String,              // required, essentially a version like "nft-1.0.0"
    pub name: String,              // required, ex. "Mosaics"
    pub symbol: String,            // required, ex. "MOSIAC"
    pub icon: Option<String>,      // Data URL
    pub base_uri: Option<String>, // Centralized gateway known to have reliable access to decentralized storage assets referenced by `reference` or `media` URLs
    pub reference: Option<String>, // URL to a JSON file with more info
    pub reference_hash: Option<Base64VecU8>, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included.
}

#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)]
#[serde(crate = "near_sdk::serde")]
pub struct TokenMetadata {
    pub title: Option<String>, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055"
    pub description: Option<String>, // free-form description
    pub media: Option<String>, // URL to associated media, preferably to decentralized, content-addressed storage
    pub media_hash: Option<Base64VecU8>, // Base64-encoded sha256 hash of content referenced by the `media` field. Required if `media` is included.
    pub copies: Option<u64>, // number of copies of this set of metadata in existence when token was minted.
    pub issued_at: Option<u64>, // When token was issued or minted, Unix epoch in milliseconds
    pub expires_at: Option<u64>, // When token expires, Unix epoch in milliseconds
    pub starts_at: Option<u64>, // When token starts being valid, Unix epoch in milliseconds
    pub updated_at: Option<u64>, // When token was last updated, Unix epoch in milliseconds
    pub extra: Option<String>, // anything extra the NFT wants to store on-chain. Can be stringified JSON.
    pub reference: Option<String>, // URL to an off-chain JSON file with more info.
    pub reference_hash: Option<Base64VecU8>, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included.
}

pub trait NonFungibleTokenMetadata {
    fn nft_metadata(&self) -> NFTMetadata;
}

#[near_bindgen]
impl NonFungibleTokenMetadata for Contract {
    fn nft_metadata(&self) -> NFTMetadata {
        self.metadata.get().unwrap()
    }
}

STEP 3 wasm 파일로 컴파일하기

cargo build --target wasm32-unknown-unknown --release
  • cargo의 build 명령어를 사용해 rust컨트랙트를 기계어로 컴파일한 wasm파일을 만든다.
.
├── Cargo.lock  ⟵ dependency들을 저장해놓는 Lock파일, 자동 생성된다.
├── Cargo.toml
├── src
│  └── 나의스마트컨트랙트.rs
└── target      ⟵ target 레포지토리 내부에 wasm 파일이 자동으로 저장된다.
  • build 이후 레포지토리에 Cargo.lock 파일과 target 폴더가 자동 생성된다.
  • target 폴더 내부에 컴파일된 wasm 파일을 확인할 수 있다.
  • target에 해당 파일이 생성된 것을 확인할 수 있다.

STEP 4 컨트랙트 블록체인에 배포하기

near cli 로그인

near login
  • near의 login 명령어를 이용해 near계정에 로그인한다.
  • near의 wallet 웹이 자동으로 실행되며, 웹에서 로그인한 결과가 near cli에 반영된다.
  • default 설정은 testnet이며, 메인넷이나 베타넷 배포를 희망할 경우 network 옵션을 추가하여 명령어를 실행하면 된다.

컨트랙트 블록체인에 배포하기

near deploy --wasmFile target/wasm32-unknown-unknown/release/<내파일이름>.wasm --accountId <나의accountID (0000.testnet)>
  • near의 deploy 명령어를 이용해 STEP 3에서 만들었던 wasm 파일을 배포한다.
  • near는 자신의 account와 컨트랙트 아이디가 매칭되는 방식을 이용한다. --accountId 옵션에 정확한 자신의 account 아이디를 적자!

배포 확인하기

  • NEAR explorer에서 자신의 어카운트 아이디를 검색하여 자신의 컨트랙트가 정상적으로 배포되었는지 확인해볼 수 있다.
profile
devlog

0개의 댓글