Skip to content

Instantly share code, notes, and snippets.

@thor314
Created May 18, 2021 15:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save thor314/92d057fc59e22d7674abfb4470a9f08c to your computer and use it in GitHub Desktop.
Save thor314/92d057fc59e22d7674abfb4470a9f08c to your computer and use it in GitHub Desktop.
//! 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