Created
May 18, 2021 15:57
-
-
Save thor314/92d057fc59e22d7674abfb4470a9f08c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! A composability module for NFT contracts. Tokens may be owned | |
//! internally, by other tokens on this contract, or externally, by tokens | |
//! on other contracts. This module makes heavy use of the `nft_holder` | |
//! method, which retrieves the holder of an nft, even if the NFT is loaned | |
//! out, or held by a token on another contract. | |
//! | |
//! Module TLDR: | |
//! - `get_compose_children`: get the children of an NFT if any exist. | |
//! - `get_compose_parent`: get the parent of an NFT if any exists. | |
//! - `nft_local_root`: get the root token of a compose chain on THIS contract. | |
//! - `nft_holder`: get the root token of a compose chain on ANY contract. | |
//! - `nfts_compose`: compose a vector of NFTs underneath a parent NFT. | |
//! - `nfts_uncompose`: flatten the ownership of a set of composed NFTs. | |
//! - `nft_cross_compose`: compose an NFT underneath an NFT on another contract. | |
//! - `nft_cross_uncompose`: flatten an NFT on this contract owned by an NFT on another contract. | |
//! Can't compose a single chain of tokens across multiple contracts. | |
//! Can't compose a token that is loaned. | |
//! Can't compose a token with non-nil `SplitOwner`. | |
//! Can't compose a token if it is already the head of a compose-chain. | |
//! | |
//! One notable limitation of composeablility: A sequence of tokens | |
//! composed across several contracts may run into edge cases where it is | |
//! difficult or impossible to uncompose tokens from the sequence. A | |
//! compromise for the sake of simplicity is made here, where a sequence of | |
//! composed tokens MAY NOT contain more than one | |
//! cross-contract-composition. Eg, where X's denote bad compositions: | |
//! | |
//! Allowed: Two tokens on C2, and another sequence of tokens on C1 owned | |
//! by a token on C1: | |
//! C1 C2 | |
//! T0 | | |
//! |\---|-\--\ | |
//! T1 | T2 T3 (allowed but not recommended: see next example) | |
//! | | |
//! T4 | |
//! | |
//! Better: use a single cross contract linkage. | |
//! C1 C2 | |
//! T0 | | |
//! |\---|-\ | |
//! T1 | T5 | |
//! | | | \ | |
//! T4 | T2 T3 | |
//! | |
//! Not allowed: Two-part cross contract ownership chain. | |
//! C1 C2 | |
//! T0 | | |
//! |----|-\--\ | |
//! T1 | T2 T3 (allowed but more recommended, see next example) | |
//! /XXX/ | |
//! T4 | | |
//! | |
//! Allowed: Token on C1 owns tokens on T2 and T3 in single cross contract linkages | |
//! C1 C2 C3 | |
//! T0 | | | |
//! |----|-\----|-\ | |
//! T1 | T2 | T3 | |
//! | |
//! Not Allowed: Token on C1 owns a token on T2 which owns another token on T3. | |
//! C1 C2 C3 | |
//! T0 | | | |
//! |----|-\ | | |
//! T1 | T2 | | |
//! | \XXX\ | |
//! | | T3 | |
//! | |
//! Composeability does NOT play nice with Loaning or Split-ownership. | |
//! Composing a token is effectively identical to transferring a token, | |
//! which nulls out split-ownership. Loaners are not allowed to transfer | |
//! tokens, and therefore, not allowed to compose tokens. | |
//! | |
//! This module does NOT play nice with approvals, but could optionally | |
//! support that as a feature. | |
//! | |
//! Composing a token at the head of a compose-chain underneath another | |
//! token MUST validate two conditions: | |
//! - First, if the token at the head of the chain is composed underneath | |
//! one of it's children (a loop), all tokens in the chain are | |
//! effectively burnt. | |
//! - Second, composing the head of a chain requires updating the | |
//! composeable_stats.depth value on the token and each of its children, | |
//! which is a significant amount of state change. | |
//! | |
//! It MAY optionally be added as a feature, but implementers be warned | |
//! that it can be gas intensive to do so. An implementation is given here. | |
use crate::*; | |
use internal::loggers; | |
use mintbase_utils::{token_key::TokenKey, DEFAULT_GAS, NO_DEPOSIT}; | |
use near_sdk::{ext_contract, json_types::U64, near_bindgen, AccountId, PromiseResult}; | |
use std::convert::TryInto; | |
/// Disallow NFT compose-chains on this contract to be longer than | |
/// `TOKEN_MAX_COMPOSE_DEPTH`. Note that a chain spanning two contracts can | |
/// be up to twice this value in length. | |
pub const TOKEN_MAX_COMPOSE_DEPTH: u8 = 4; | |
#[ext_contract(ext_on_compose)] | |
pub trait OnCompose { | |
/// `nft_cross_compose` calls this method on another NFT contract: on | |
/// receiving a call, this contract composes | |
/// `cross_child_id:<calling_contract_id>` underneath `token_id`. | |
/// | |
/// This method should update the `self.composeables` map to track | |
/// composed tokens on this contract. | |
/// | |
/// Assert this token is not beneath another cross-contract compose chain. | |
/// | |
/// This method expects a deposit of to cover 80b storage. | |
#[payable] | |
fn on_compose(&mut self, token_id: U64, cross_child_id: U64) -> Promise; | |
/// `nft_cross_uncompose` calls this method on another NFT contract to | |
/// "flatten" token ownership: the `owner_id` field of `token_id` id will | |
/// be updated to match the owner of `token_id`s parent. | |
/// | |
/// This method MUST validate that the `signer_account_id` owns the | |
/// top-level parent token. | |
/// | |
/// Return the `AccountId` that holds `token_id`, so that the calling | |
/// contract may flatten its owner to that `AccountId`. | |
fn on_uncompose(&mut self, token_id: U64, cross_child_id: U64) -> AccountId; | |
} | |
#[ext_contract(ext_self)] | |
pub trait ExtSelf { | |
/// Capture the 0th `PromiseResult`. Map `Successful` to: | |
/// 1. Compose `token_id` underneath `cross_parent_id`, updating | |
/// `owner_id` to "cross_parent_id:contract_account". | |
/// 2. Update the `self.composeables` map, updating the set for | |
/// `cross_parent_id:contract_account` to contain `token_id`. | |
#[private] | |
fn cross_compose_callback( | |
&mut self, | |
cross_parent_id: U64, | |
token_id: U64, | |
account_id: ValidAccountId, | |
); | |
/// Capture the 0th `PromiseResult`. Map `Successful<AccountId>` to: 1. | |
/// Update the `owner_id` for `token_id` to the returned `AccountId` 2. | |
/// Update the `composeables` map, removing `token_id` from | |
/// `cross_parent_id`'s set. | |
#[private] | |
fn cross_uncompose_callback( | |
&mut self, | |
cross_parent_id: U64, | |
token_id: U64, | |
account_id: ValidAccountId, | |
); | |
} | |
#[near_bindgen] | |
impl MintbaseStore { | |
/// Get the tokens directly owned by this token. This method will not | |
/// query if the children own further composed tokens. `token_id` has | |
/// type string because this method may be used to query tokens of form: | |
/// - "normal_account_id.near" - | |
/// "<other_contract_token_id>:<other_contract_account>" | |
pub fn get_compose_children(&self, token_id: String) -> Option<Vec<String>> { | |
self.composeables.get(&token_id).map(|v| v.to_vec()) | |
} | |
/// If the token is composed, return the token that directly holds this | |
/// token. Otherwise (if the token is owned by an account, not owned by | |
/// another token), return None. | |
pub fn get_compose_parent(&self, token_id: U64) -> Option<String> { | |
match self.nft_token_internal(token_id.into()).owner_id { | |
Owner::TokenId(n) => Some(n.to_string()), | |
Owner::CrossKey(k) => Some(k.to_string()), | |
Owner::Account(_) => None, | |
} | |
} | |
/// If the token is composed, return the root token_id of the compose chain | |
/// on THIS contract (not a cross-key). To get the cross-contract root, | |
/// call `nft_holder.` | |
pub fn nft_local_root_id(&self, token_id: U64) -> Option<u64> { | |
let token = self.nft_token_internal(token_id.into()); | |
if let Owner::TokenId(id) = token.get_owner_or_loaner() { | |
let mut parent = self.nft_token_internal(id); | |
while let Owner::TokenId(id) = parent.owner_id { | |
parent = self.nft_token_internal(id); | |
} | |
Some(parent.id) | |
} else { | |
None | |
} | |
} | |
/// Recursively update the depths of the tokens in `set_composed`. | |
fn update_set_depth_recursive(&mut self, set_composed: &mut UnorderedSet<String>, new_depth: u8) { | |
assert!(new_depth <= TOKEN_MAX_COMPOSE_DEPTH); | |
set_composed.iter().for_each(|id| { | |
// There may be cross-contract composed tokens that will fail this, | |
// but they don't need to be updated. | |
if let Ok(id) = id.parse::<u64>() { | |
let mut token = self.nft_token_internal(id); | |
token.composeable_stats.local_depth = new_depth; | |
self.tokens.insert(&token.id, &token); | |
if let Some(mut set) = self.composeables.get(&id.to_string()) { | |
self.update_set_depth_recursive(&mut set, new_depth + 1); | |
} | |
} | |
}); | |
} | |
/// Compose a vector of tokens on this contract underneath another token | |
/// on this contract. | |
/// | |
/// Note that the best way to compose a set of tokens on THIS contract as | |
/// children underneath a token on ANOTHER contract, is to: | |
/// 1. Create a top-level token on this contract | |
/// 2. Compose the set of tokens underneath the top level token | |
/// 3. Compose the top level token with `nft_cross_compose`. | |
/// | |
/// Validate: | |
/// - `token_ids` is not empty | |
/// - caller has attached cover for 80b storage * number of tokens | |
/// - `parent_id` exists on this contract | |
/// - For each token validate: | |
/// disallow it from being composed underneath another compose chain | |
/// - not loaned: loaners cannot transfer tokens | |
/// - predecessor is owner; this method does NOT play nice with approvals. | |
/// - the parent token depth is less than TOKEN_MAX_COMPOSE_DEPTH | |
/// - If the token composeably owns other tokens, recursively update | |
/// the depth value for all of its children. Beware that this can be | |
/// gas expensive. | |
/// | |
/// If the token is Split-Owned, null the `SplitOwner` field, as | |
/// composing is effectively identical to transferring the token. | |
/// | |
/// This method updates the contract `composeables` map and the `owner_id` | |
/// field of the tokens in `token_ids`. | |
#[payable] | |
pub fn nfts_compose(&mut self, token_ids: Vec<U64>, parent_id: U64) { | |
assert!(!token_ids.is_empty()); | |
let storage_cost = self.storage_costs.common * token_ids.len() as u128; | |
assert!(env::attached_deposit() >= storage_cost); | |
assert!(self.nft_token(parent_id).is_some()); | |
let parent_idu64: u64 = parent_id.into(); | |
let parent_depth_plus_one = self | |
.nft_token_internal(parent_idu64) | |
.composeable_stats | |
.local_depth | |
+ 1; | |
assert!(parent_depth_plus_one < TOKEN_MAX_COMPOSE_DEPTH); | |
let pred = env::predecessor_account_id(); | |
let mut set_owned = self | |
.tokens_per_owner | |
.get(&pred) | |
.expect("caller owns no tokens"); | |
let mut set_composed = self.get_or_new_composed(parent_idu64.to_string()); | |
token_ids.iter().for_each(|&token_id| { | |
let token_idu64 = token_id.into(); | |
let mut token = self.nft_token_internal(token_idu64); | |
assert!(!token.is_loaned()); | |
assert!(token.is_pred_owner()); | |
// prevent loops, validate root is not equal to token_id | |
if let Some(id) = self.nft_local_root_id(parent_id) { | |
assert_ne!(token_id.0, id); | |
} | |
// if token_id has tokens composed beneath it, get the depth of | |
// parent, and update the depths of tokens composed under token_id | |
if let Some(mut set) = self.composeables.get(&token_id.0.to_string()) { | |
// token_id has depth parent_depth+1, it's children have depth THAT+1 again | |
self.update_set_depth_recursive(&mut set, parent_depth_plus_one + 1); | |
} | |
// set the compose depth, remove from owner set, null any SplitOwner, | |
// insert into composed set, and update owner to parent's TokenId. | |
token.composeable_stats.local_depth = parent_depth_plus_one; | |
token.split_owners = None; | |
token.owner_id = Owner::TokenId(parent_idu64); | |
self.tokens.insert(&token_idu64, &token); | |
set_composed.insert(&token_idu64.to_string()); | |
set_owned.remove(&token_idu64); | |
}); | |
self.tokens_per_owner.insert(&pred, &set_owned); | |
self | |
.composeables | |
.insert(&parent_idu64.to_string(), &set_composed); | |
loggers::log_nfts_compose( | |
&token_ids, | |
&parent_idu64.to_string(), | |
"t".to_string(), | |
self.nft_local_root_id(parent_id), | |
self.nft_holder(parent_id), | |
parent_depth_plus_one, | |
); | |
} | |
/// Flatten the ownership of a vector of `token_ids`. This method will | |
/// fail if the top-level parent is a token on another contract. Instead, | |
/// call `nft_cross_uncompose`. | |
/// | |
/// Validate: | |
/// - one yoctoNear attached. | |
/// - token_ids is not empty | |
/// - for each token: | |
/// - predecessor is the owner of the top-level parent token | |
/// | |
/// This method updates the contract `composeables` map and the `owner_id` | |
/// field of the tokens in `token_ids`. | |
#[payable] | |
pub fn nfts_uncompose(&mut self, token_ids: Vec<U64>) { | |
near_sdk::assert_one_yocto(); | |
assert!(!token_ids.is_empty()); | |
let pred = env::predecessor_account_id(); | |
let mut set_owned = self.get_or_make_new_owner_set(&pred); | |
token_ids.iter().for_each(|&token_id| { | |
let token_idu64: u64 = token_id.into(); | |
let mut token = self.nft_token_internal(token_idu64); | |
// if a cross key is returned by nft_holder, this assertion fails. | |
assert_eq!(self.nft_holder(token_id), pred); | |
let parentu64 = match token.owner_id { | |
Owner::Account(_) => env::panic(format!("{} is uncomposed", token.id).as_bytes()), | |
Owner::TokenId(n) => n, | |
Owner::CrossKey(key) => { | |
env::panic(format!("{} composed on contract: {}", token.id, key.account_id).as_bytes()) | |
} | |
}; | |
// can't assume the tokens share a root, therefore cannot batch the following line: | |
self.update_composed_sets(token_idu64.to_string(), parentu64.to_string(), false); | |
set_owned.insert(&token_idu64); | |
token.composeable_stats.local_depth = 0; | |
token.owner_id = Owner::Account(pred.to_string()); | |
self.tokens.insert(&token_idu64, &token); | |
}); | |
self.tokens_per_owner.insert(&pred, &set_owned); | |
loggers::log_nfts_uncompose(&token_ids, pred); | |
} | |
/// Set the compose-parent of a token on this contract to an NFT on | |
/// another contract. A token may own other tokens on this contract, | |
/// which allows a user to grant a token on another contract ownership of | |
/// several tokens with a single cross-contract call. | |
/// | |
/// This method MUST validate that `token_id` is not the compose-parent | |
/// of any tokens on other contracts, as this would break the | |
/// only-one-cross-linkage invariant. | |
/// | |
/// This method MUST also validate that the token is NOT loaned, and that | |
/// the predecessor is the token owner. | |
#[payable] | |
pub fn nft_cross_compose( | |
&mut self, | |
token_id: U64, | |
cross_parent_id: U64, | |
account_id: ValidAccountId, | |
) { | |
// assume the other contract wants at least as much as this contract for storage | |
let storage_cost = self.storage_costs.common * 2; // 2: one here, one across | |
assert!(env::attached_deposit() >= storage_cost); | |
let token_idu64: u64 = token_id.into(); | |
let token = self.nft_token_internal(token_idu64); | |
assert_eq!(token.composeable_stats.cross_contract_children, 0); | |
assert!(!token.is_loaned()); | |
assert!(token.is_pred_owner()); | |
ext_on_compose::on_compose( | |
cross_parent_id, | |
token_id, | |
&account_id, | |
env::attached_deposit() - self.storage_costs.common, | |
mintbase_utils::DEFAULT_GAS / 4, | |
) | |
.then(ext_self::cross_compose_callback( | |
cross_parent_id, | |
token_id, | |
account_id, | |
&env::current_account_id(), | |
NO_DEPOSIT, | |
DEFAULT_GAS / 4, | |
)); | |
} | |
/// Flatten the ownership of a `token_id` owned by a token on another | |
/// contract. This method will fail if the top-level parent is a token on | |
/// another contract. Instead, call `nft_uncompose`. | |
/// | |
/// Validate: | |
/// - one yoctoNear attached. | |
/// - token is owned by a token on another contract | |
#[payable] | |
pub fn nft_cross_uncompose(&mut self, token_id: U64) { | |
near_sdk::assert_one_yocto(); | |
let token = self.nft_token_internal(token_id.into()); | |
let cross_key = match token.owner_id { | |
Owner::CrossKey(key) => key, | |
_ => env::panic(b"token not cross composed"), | |
}; | |
ext_on_compose::on_uncompose( | |
cross_key.token_id.into(), | |
token_id, | |
&cross_key.account_id, | |
NO_DEPOSIT, | |
DEFAULT_GAS / 4, | |
) | |
.then(ext_self::cross_uncompose_callback( | |
cross_key.token_id.into(), | |
token_id, | |
cross_key.account_id.try_into().unwrap(), | |
&env::current_account_id(), | |
NO_DEPOSIT, | |
DEFAULT_GAS / 4, | |
)); | |
} | |
/// Recursive helper for `on_(un)compose` to update the | |
/// `cross_contract_children` for each parent token in the chain. | |
/// | |
/// If increment is true, add one, otherwise subtract one. | |
fn update_cross_contract_children_recursive(&mut self, token: &mut Token, increment: bool) { | |
if increment { | |
// will overflow at 255, causing panic | |
token.composeable_stats.cross_contract_children += 1; | |
} else { | |
token.composeable_stats.cross_contract_children -= 1; | |
} | |
self.tokens.insert(&token.id, &token); | |
if let Owner::TokenId(id) = token.owner_id { | |
self.update_cross_contract_children_recursive(&mut self.nft_token_internal(id), increment) | |
} | |
} | |
#[payable] | |
pub fn on_compose(&mut self, token_id: U64, cross_child_id: U64) { | |
assert!(env::attached_deposit() >= self.storage_costs.common); | |
// assert this token is not beneath another cross-contract compose chain. | |
let root_id = self.nft_local_root_id(token_id); | |
let holder = if let Some(root_id) = root_id { | |
let root = self.nft_token_internal(root_id); | |
if let Owner::CrossKey(key) = root.owner_id { | |
env::panic(format!("token_id is cross-composed underneath: {}", key).as_bytes()); | |
} | |
self.nft_holder(root_id.into()) | |
} else { | |
self.nft_holder(token_id) | |
}; | |
let parent_idu64: u64 = token_id.into(); | |
let mut parent = self.nft_token_internal(parent_idu64); | |
self.update_cross_contract_children_recursive(&mut parent, true); | |
let child_idu64: u64 = cross_child_id.into(); | |
let child_key = TokenKey::new( | |
child_idu64, | |
env::predecessor_account_id().try_into().unwrap(), | |
); | |
self.update_composed_sets(child_key.to_string(), parent_idu64.to_string(), true); | |
loggers::on_compose( | |
env::predecessor_account_id(), | |
token_id, | |
cross_child_id, | |
root_id, | |
holder, | |
parent.composeable_stats.local_depth, | |
); | |
} | |
pub fn on_uncompose(&mut self, token_id: U64, cross_child_id: U64) -> AccountId { | |
assert_eq!(env::signer_account_id(), self.nft_holder(token_id)); | |
let mut token = self.nft_token_internal(token_id.into()); | |
self.update_cross_contract_children_recursive(&mut token, false); | |
let child_idu64: u64 = cross_child_id.into(); | |
let child_key = TokenKey::new( | |
child_idu64, | |
env::predecessor_account_id().try_into().unwrap(), | |
); | |
let parent_idu64: u64 = token_id.into(); | |
self.update_composed_sets(child_key.to_string(), parent_idu64.to_string(), false); | |
let holder = self.nft_holder(token_id); | |
loggers::log_on_uncompose(token_id, &holder, child_key.to_string()); | |
holder | |
} | |
#[private] | |
pub fn cross_compose_callback( | |
&mut self, | |
cross_parent_id: U64, | |
token_id: U64, | |
account_id: ValidAccountId, | |
) { | |
match env::promise_result(0) { | |
PromiseResult::Successful(_) => { | |
let mut token = self.nft_token_internal(token_id.into()); | |
let parentu64: u64 = cross_parent_id.into(); | |
let parent_key = TokenKey::new(parentu64, account_id); | |
self.update_tokens_per_owner(token.id, Some(token.owner_id.to_string()), None); | |
self.update_composed_sets(token.id.to_string(), parent_key.to_string(), true); | |
token.owner_id = Owner::CrossKey(parent_key.clone()); | |
self.tokens.insert(&token.id, &token); | |
loggers::log_nfts_compose( | |
&[token.id.into()], | |
&parent_key.to_string(), | |
"k".to_string(), | |
Some(token.id), | |
parent_key.to_string(), | |
0, | |
) | |
} | |
PromiseResult::NotReady => (), | |
PromiseResult::Failed => (), | |
} | |
} | |
#[private] | |
pub fn cross_uncompose_callback( | |
&mut self, | |
cross_parent_id: U64, | |
token_id: U64, | |
account_id: ValidAccountId, | |
) { | |
{ | |
match env::promise_result(0) { | |
PromiseResult::Successful(holder) => { | |
let holder: Result<String, _> = near_sdk::serde_json::from_slice(&holder); | |
match holder { | |
Err(_) => env::panic(b"could not parse holder"), | |
Ok(holder) => { | |
let parent_key = TokenKey::new(cross_parent_id.into(), account_id); | |
let token_idu64: u64 = token_id.into(); | |
self.update_tokens_per_owner(token_id.into(), None, Some(holder.to_string())); | |
self.update_composed_sets(token_idu64.to_string(), parent_key.to_string(), false); | |
let mut token = self.nft_token_internal(token_idu64); | |
token.owner_id = Owner::Account(holder.to_string()); | |
self.tokens.insert(&token_idu64, &token); | |
loggers::log_nfts_uncompose(&[token_id], holder) | |
} | |
} | |
} | |
PromiseResult::NotReady => {} | |
PromiseResult::Failed => {} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment