내가 느낀 블록체인 개발의 가장 큰 장벽은 자세한/정확한 레퍼런스를 찾기 어렵다는 것이었다. 특히 한국어로 적혀있는 튜토리얼은 거의 없다고 보면 되어서, 적응하기 쉽지 않았다.
나는 졸업 프로젝트에서 블록체인 개발을 하며 NEAR 프로토콜의 NEAR University의 튜토리얼과 NEAR discord에 직접 질문하는 방식을 이용하여 막힌 부분들을 풀었다.
이 글이 하나의 좋은 한국어 개발 튜토리얼이 되길 바라며! 내가 사용한 Rust 스마트 컨트랙트 작성과 NEAR 테스트넷에 배포하는 방식을 적어보겠다.
$ cargo new <나의 레포지토리 이름>
$ cd <레포지토리>
.
├── Cargo.toml
└── src
└── main.rs
스마트 컨트랙트를 계약 내용을 직접 명시하는 것이나 현실의 계약서와 같은 개념이라고 보면 개발할 때 많은 혼란이 있을 것이다. 스마트 컨트랙트는 하나의 인터페이스, 블록체인에 올라가는 데이터 틀을 만든다고 생각하면 더 편하게 이해할 수 있다.
나는 NFT를 발행하고 거래하는 플랫폼을 개발 중이어서 NEAR의 nft표준인 NEP-171
표준을 따르는 스마트 컨트랙트를 작성했다. NEP-171
표준을 사용하여 컨트랙트를 작성하면 해당 표준을 공유하는 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);
}
}
NEP-171
표준을 구현한다.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
}
}
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()
}
}
cargo build --target wasm32-unknown-unknown --release
.
├── Cargo.lock ⟵ dependency들을 저장해놓는 Lock파일, 자동 생성된다.
├── Cargo.toml
├── src
│ └── 나의스마트컨트랙트.rs
└── target ⟵ target 레포지토리 내부에 wasm 파일이 자동으로 저장된다.
near login
near deploy --wasmFile target/wasm32-unknown-unknown/release/<내파일이름>.wasm --accountId <나의accountID (0000.testnet)>
--accountId
옵션에 정확한 자신의 account 아이디를 적자!