Part One: Implementing NFTs in Sapio

Day 19: Rubin's Bitcoin Advent Calendar

on December 16, 2021

This post is syndicated from rubin.io.

Welcome to day 19 of my Bitcoin Advent Calendar. You can see an index of all the posts here or subscribe at judica.org/join to get new posts in your inbox

For today’s post we’re going to build out some Sapio NFT protocols that are client-side verifiable. Today we’ll focus on code, tomorrow we’ll do more discussion and showing how they work. I was sick last night (minor burrito oriented food poisoning suspected) and so I got behind, hence this post being up late.

As usual, the disclaimer that as I’ve been behind… so we’re less focused today on correctness and more focused on giving you the shape of the idea. In other words, I’m almost positive it won’t work properly, but it can compile! And the general flow looks correct.

There’s also a couple new concepts I want to adopt as I’ve been working on this, so those are things that will have to happen as I refine this idea to be production grade.

Before we start, let’s get an eagle-eye view of the ‘system’ we’re going to be building, because it represents multiple modules and logical components.

By the end, we’ll have 5 separate things:

  1. An Abstract NFT Interface
  2. An Abstract Sellable Interface
  3. An Abstract Sale Interface
  4. A Concrete Sellable NFT (Simple NFT)
  5. A Concrete Sale Interface (Simple NFT Sale)

In words:

Simple NFT implements both NFT and Sellable, and has a sell function that can be called with any Sale module.

Simple NFT Sale implements Sale, and can be used with the sell of anything that implements Sellable and NFT.

We can make other implementations of Sale and NFT and they should be compatible.

How’s it going to ‘work’?

Essentially how this is going to work do is

  1. An artist mint an NFT.
  2. The artist can sell it to anyone whose bids the artist accepts

Normally, in Ethereum NFTs, you could do something for step 2:

with Bitcoin NFTs, it’s a little different. The artist has to run a server that accepts bids above the owner’s current price threshold and returns signed under-funded transaction that would pay the owner the asking price. Alternatively, the bidder can send an open-bid that the owner can fill immediately.

Because Sapio is super-duper cool, we can make abstract interfaces for this stuff so that NFTs can have lots of neat features like enforcing royalties, dutch auction prices, batch minting, generative art minting, and more. We’ll see a bit more tomorrow.

Client validation is central to this story. A lot of the rules are not enforced by the Bitcoin blockchain. They are, however, enforced by requiring that the ‘auditor’ be able to re-reach the same identical contract state by re-compiling the entire contract from the start. I.e., as long as you generate all your state transitions through Sapio, you can verify that an NFT is ‘authentic’. Of course, anyone can ‘burn’ an NFT if they want by sending e.g. to an unknown key. Client side validation just posits that sending to an unknown key is ‘on the same level’ of error as corrupting an NFT by doing state transitions without having the corresponding ‘witness’ of sapio effects to generate the transfer.

Please re-read this section after you get throught the code (I’ll remind you).

Declaring an NFT Minting Interface

First we are going to declare the basic information for a NFT.

Every NFT should have a owner (PublicKey) and a locator (some url, IPFS hash, etc).

NFTs also should track which Sapio module was used to mint them, to ensure compatibility going forward. If it’s not known, modules can try to fill it in and guess (e.g., a good gues is “this module”).

Let’s put that to code:

/// # Trait for a Mintable NFT
#[derive(Serialize, JsonSchema, Deserialize, Clone)]
pub struct Mint_NFT_Trait_Version_0_1_0 {
    /// # Initial Owner
    /// The key that will own this NFT
    pub owner: bitcoin::PublicKey,
    /// # Locator
    /// A piece of information that will instruct us where the NFT can be
    /// downloaded -- e.g. an IPFs Hash
    pub locator: String,
    /// # Minting Module
    /// If a specific sub-module is to be used / known -- when in doubt, should
    /// be None.
    pub minting_module: Option<SapioHostAPI<Mint_NFT_Trait_Version_0_1_0>>,
}

/// Boilerplate for the Mint trait
pub mod mint_impl {
    use super::*;
    #[derive(Serialize, Deserialize, JsonSchema)]
    pub enum Versions {
        Mint_NFT_Trait_Version_0_1_0(Mint_NFT_Trait_Version_0_1_0),
    }
    /// we must provide an example!
    impl SapioJSONTrait for Mint_NFT_Trait_Version_0_1_0 {
        fn get_example_for_api_checking() -> Value {
            let key = "02996fe4ed5943b281ca8cac92b2d0761f36cc735820579da355b737fb94b828fa";
            let ipfs_hash = "bafkreig7r2tdlwqxzlwnd7aqhkkvzjqv53oyrkfnhksijkvmc6k57uqk6a";
            serde_json::to_value(mint_impl::Versions::Mint_NFT_Trait_Version_0_1_0(
                Mint_NFT_Trait_Version_0_1_0 {
                    owner: bitcoin::PublicKey::from_str(key).unwrap(),
                    locator: ipfs_hash.into(),
                    minting_module: None,
                },
            ))
            .unwrap()
        }
    }
}

Shaweeeeet! We have an NFT Minting Interface!

But you can’t actually use it to Mint yet, since we lack an Implementation.

Before we implement it…

What are NFTs Good For? Selling! (Sales Interface)

If you have an NFT, you probably will want to sell it in the future. Let’s declare a sales interface.

To sell an NFT we need to know:

  1. Who currently owns it
  2. Who is buying it
  3. What they are paying for it
  4. Maybe some extra stuff

/// # NFT Sale Trait
/// A trait for coordinating a sale of an NFT
#[derive(Serialize, JsonSchema, Deserialize, Clone)]
pub struct NFT_Sale_Trait_Version_0_1_0 {
    /// # Owner
    /// The key that will own this NFT
    pub sell_to: bitcoin::PublicKey,
    /// # Price
    /// The price in Sats
    pub price: AmountU64,
    /// # NFT
    /// The NFT's Current Info
    pub data: Mint_NFT_Trait_Version_0_1_0,
    /// # Extra Information
    /// Extra information required by this contract, if any.
    /// Must be Optional for consumer or typechecking will fail.
    /// Usually None unless you know better!
    pub extra: Option<Value>,
}

/// Boilerplate for the Sale trait
pub mod sale_impl {
    use super::*;
    #[derive(Serialize, Deserialize, JsonSchema)]
    pub enum Versions {
        /// # Batching Trait API
        NFT_Sale_Trait_Version_0_1_0(NFT_Sale_Trait_Version_0_1_0),
    }
    impl SapioJSONTrait for NFT_Sale_Trait_Version_0_1_0 {
        fn get_example_for_api_checking() -> Value {
            let key = "02996fe4ed5943b281ca8cac92b2d0761f36cc735820579da355b737fb94b828fa";
            let ipfs_hash = "bafkreig7r2tdlwqxzlwnd7aqhkkvzjqv53oyrkfnhksijkvmc6k57uqk6a";
            serde_json::to_value(sale_impl::Versions::NFT_Sale_Trait_Version_0_1_0(
                NFT_Sale_Trait_Version_0_1_0 {
                    sell_to: bitcoin::PublicKey::from_str(key).unwrap(),
                    price: Amount::from_sat(0).into(),
                    data: Mint_NFT_Trait_Version_0_1_0 {
                        owner: bitcoin::PublicKey::from_str(key).unwrap(),
                        locator: ipfs_hash.into(),
                        minting_module: None,
                    },
                    extra: None,
                },
            ))
            .unwrap()
        }
    }
}

That’s the interface for the contract that sells the NFTs. We also need an interface for NFTs that want to initiate a sale.

To do that, we need to know:

  1. What kind of sale we are doing
  2. The data for that sale

This is really just expressing that we need to bind a NFT Sale Implementation to our contract. We can express the sale interface as follows.



/// # Sellable NFT Function
/// If a NFT should be sellable, it should have this trait implemented.
pub trait SellableNFT: Contract {
    decl_continuation! {<web={}> sell<Sell>}
}
/// # Sell Instructions
#[derive(Serialize, Deserialize, JsonSchema)]
pub enum Sell {
    /// # Hold
    /// Don't transfer this NFT
    Hold,
    /// # MakeSale
    /// Transfer this NFT
    MakeSale {
        /// # Which Sale Contract to use?
        /// Specify a hash/name for a contract to generate the sale with.
        which_sale: SapioHostAPI<NFT_Sale_Trait_Version_0_1_0>,
        /// # The information needed to create the sale
        sale_info: NFT_Sale_Trait_Version_0_1_0,
    },
}
impl Default for Sell {
    fn default() -> Sell {
        Sell::Hold
    }
}
impl StatefulArgumentsTrait for Sell {}

Getting Concrete: Making an NFT

Let’s create a really simple NFT now that implements these interfaces.

There’s a bit of boilerplate, so we’ll go section-by-section.

First, let’s declare the SimpleNFT

/// # SimpleNFT
/// A really simple NFT... not much too it!
#[derive(JsonSchema, Serialize, Deserialize)]
pub struct SimpleNFT {
    /// The minting data, and nothing else.
    data: Mint_NFT_Trait_Version_0_1_0,
}

/// # The SimpleNFT Contract
impl Contract for SimpleNFT {
    // NFTs... only good for selling?
    declare! {updatable<Sell>, Self::sell}
    // embeds metadata
    declare! {then, Self::metadata_txns}
}

First, let’s implement the logic for selling the NFT… You remember our old friend the Sales interface?

impl SimpleNFT {
    /// # signed
    /// Get the current owners signature.
    #[guard]
    fn signed(self, ctx: Context) {
        Clause::Key(self.data.owner.clone())
    }
}
fn default_coerce(k: <SimpleNFT as Contract>::StatefulArguments) -> Result<Sell, CompilationError> {
    Ok(k)
}

impl SellableNFT for SimpleNFT {
    #[continuation(guarded_by = "[Self::signed]", web_api, coerce_args = "default_coerce")]
    fn sell(self, ctx: Context, sale: Sell) {
        if let Sell::MakeSale {
            sale_info,
            which_sale,
        } = sale
        {
            // if we're selling...
            if sale_info.data.owner != self.data.owner {
                // Hmmm... metadata mismatch! the current owner does not
                // matched the sale's claimed owner.
                return Err(CompilationError::TerminateCompilation);
            }
            // create a contract from the sale API passed in
            let compiled = Ok(CreateArgs {
                context: ContextualArguments {
                    amount: ctx.funds(),
                    network: ctx.network,
                    effects: unsafe { ctx.get_effects_internal() }.as_ref().clone(),
                },
                arguments: sale_impl::Versions::NFT_Sale_Trait_Version_0_1_0(sale_info.clone()),
            })
            .map(serde_json::to_value)
            // use the sale API we passed in
            .map(|args| create_contract_by_key(&which_sale.key, args, Amount::from_sat(0)))
            // handle errors...
            .map_err(|_| CompilationError::TerminateCompilation)?
            .ok_or(CompilationError::TerminateCompilation)?;
            // send to this sale!
            let mut builder = ctx.template();
            // todo: we need to cut-through the compiled contract address, but this
            // upgrade to Sapio semantics will come Soon™.
            builder = builder.add_output(compiled.amount_range.max(), &compiled, None)?;
            builder.into()
        } else {
            /// Don't do anything if we're holding!
            empty()
        }
    }
}

Next, let’s implement the metadata logic. There are a million ways to do metadata, so feel free to ‘skip’ this section and just let your mind wander on interesting things you could do here…

impl SimpleNFT {
    /// # unspendable
    /// what? This is just a sneaky way of making a provably unspendable branch
    /// (since the preimage of [0u8; 32] hash can never be found). We use that to
    /// help us embed metadata inside of our contract...
    #[guard]
    fn unspendable(self, ctx: Context) {
        Clause::Sha256(sha256::Hash::from_inner([0u8; 32]))
    }
    /// # Metadata TXNs
    /// This metadata TXN is provably unspendable because it is guarded
    /// by `Self::unspendable`. Neat!
    /// Here, we simple embed a OP_RETURN.
    /// But you could imagine tracking (& client side validating)
    /// an entire tree of transactions based on state transitions with these
    /// transactions... in a future post, we'll see more!
    #[then(guarded_by = "[Self::unspendable]")]
    fn metadata_txns(self, ctx: Context) {
        ctx.template()
            .add_output(
                Amount::ZERO,
                &Compiled::from_op_return(
                    &sha256::Hash::hash(&self.data.locator.as_bytes()).as_inner()[..],
                )?,
                None,
            )?
            // note: what if we also comitted to the hash of the wasm module
            // compiling this contract?
            .into()
    }
}

Lastly, some icky boilerplate stuff:

#[derive(Serialize, Deserialize, JsonSchema)]
enum Versions {
    Mint_NFT_Trait_Version_0_1_0(Mint_NFT_Trait_Version_0_1_0),
}

impl TryFrom<Versions> for SimpleNFT {
    type Error = CompilationError;
    fn try_from(v: Versions) -> Result<Self, Self::Error> {
        let Versions::Mint_NFT_Trait_Version_0_1_0(mut data) = v;
        let this = LookupFrom::This
            .try_into()
            .map_err(|_| CompilationError::TerminateCompilation)?;
        match data.minting_module {
            // if no module is provided, it must be this module!
            None => {
                data.minting_module = Some(this);
                Ok(SimpleNFT { data })
            }
            // if a module is provided, we have no idea what to do...
            // unless the module is this module itself!
            Some(ref module) if module.key == this.key => Ok(SimpleNFT { data }),
            _ => Err(CompilationError::TerminateCompilation),
        }
    }
}
REGISTER![[SimpleNFT, Versions], "logo.png"];

Right on! Now we have made a NFT Implementation. We can Mint one, but wait.

How do we sell it?

We need a NFT Sale Implementation

So let’s do it. In today’s post, we’ll implement the most boring lame ass Sale…

Tomorrow we’ll do more fun stuff, I swear.

First, let’s get our boring declarations out of the way:


/// # Simple NFT Sale
/// A Sale which simply transfers the NFT for a fixed price.
#[derive(JsonSchema, Serialize, Deserialize)]
pub struct SimpleNFTSale(NFT_Sale_Trait_Version_0_1_0);

/// # Versions Trait Wrapper
#[derive(Serialize, Deserialize, JsonSchema)]
enum Versions {
    /// # Batching Trait API
    NFT_Sale_Trait_Version_0_1_0(NFT_Sale_Trait_Version_0_1_0),
}
impl Contract for SimpleNFTSale {
    declare! {updatable<()>, Self::transfer}
}
fn default_coerce<T>(_: T) -> Result<(), CompilationError> {
    Ok(())
}
impl From<Versions> for SimpleNFTSale {
    fn from(v: Versions) -> SimpleNFTSale {
        let Versions::NFT_Sale_Trait_Version_0_1_0(x) = v;
        SimpleNFTSale(x)
    }
}

REGISTER![[SimpleNFTSale, Versions], "logo.png"];

Now, onto the logic of a sale!



impl SimpleNFTSale {
    /// # signed
    /// sales must be signed by the current owner
    #[guard]
    fn signed(self, ctx: Context) {
        Clause::Key(self.0.data.owner.clone())
    }
    /// # transfer
    /// transfer exchanges the NFT for cold hard Bitcoinz
    #[continuation(guarded_by = "[Self::signed]", web_api, coerce_args = "default_coerce")]
    fn transfer(self, ctx: Context, u: ()) {
        let amt = ctx.funds();
        // first, let's get the module that should be used to 're-mint' this NFT
        // to the new owner
        let key = self
            .0
            .data
            .minting_module
            .clone()
            .ok_or(CompilationError::TerminateCompilation)?
            .key;
        // let's make a copy of the old nft metadata..
        let mut mint_data = self.0.data.clone();
        // and change the owner to the buyer
        mint_data.owner = self.0.sell_to;
        // let's now compile a new 'mint' of the NFT
        let new_nft_contract = Ok(CreateArgs {
            context: ContextualArguments {
                amount: ctx.funds(),
                network: ctx.network,
                effects: unsafe { ctx.get_effects_internal() }.as_ref().clone(),
            },
            arguments: mint_impl::Versions::Mint_NFT_Trait_Version_0_1_0(mint_data),
        })
        .and_then(serde_json::to_value)
        .map(|args| create_contract_by_key(&key, args, Amount::from_sat(0)))
        .map_err(|_| CompilationError::TerminateCompilation)?
        .ok_or(CompilationError::TerminateCompilation)?;
        // Now for the magic:
        // This is a transaction that creates at output 0 the new nft for the
        // person, and must add another input that pays sufficiently to pay the
        // prior owner an amount.

        // todo: we also could use cut-through here once implemented
        // todo: change seem problematic here? with a bit of work, we could handle it
        // cleanly if the buyer identifys an output they are spending before requesting
        // a purchase.
        ctx.template()
            .add_output(amt, &new_nft_contract, None)?
            .add_amount(self.0.price.into())
            .add_sequence()
            .add_output(self.0.price.into(), &self.0.data.owner, None)?
            // note: what would happen if we had another output that 
            // had a percentage-of-sale royalty to some creator's key?
            .into()
    }
}

And that’s it! Makes sense, right? I hope…

But if not

Re read the part before the code again! Maybe it will be more clear now :)


comments powered by Disqus