stacksgov / sips

Community-submitted Stacks Improvement Proposals (SIPs)
133 stars 80 forks source link

SIP-013 Semi-Fungible Token standard #42

Closed MarvinJanssen closed 1 year ago

MarvinJanssen commented 2 years ago

This SIP proposes a standard for semi-fungible tokens. Semi-Fungible Tokens, or SFTs, are digital assets that sit between fungible and non-fungible tokens. Fungible tokens are directly interchangeable, can be received, sent, and divided. Non-fungible tokens each have a unique identifier that distinguishes them from each other. Semi-fungible tokens have both an identifier and an amount.

The standard is still in draft stage but a PR is the best way to request comments and feedback. Copying some text from my reference repo for brevity.

Uses

Semi-fungible tokens can be very useful in many different settings. Here are some examples:

Art

Art initiatives can use them to group an entire project into a single contract and mint multiple series or collections in a single go. A single artwork can have multiple editions that can all be expressed by the same identifier. Artists can also use them to easily create a track-record of their work over time. Curation requires tracking a single contract instead of a new one per project.

Games

Games that have on-chain economies can leverage their flexibility to express their full in-game inventory in a single contract. For example, they may express their in-game currency with one token ID and a commodity with another. In-game item supplies can be managed in a more straightforward way and the game developers can introduce new item classes in a transparent manner.

SFTs and post conditions

Post conditions are tricky because it is impossible to make assertions based on custom print events as of Stacks 2.0. Still, native events can be utilised to safeguard SFT actions in different ways. The reference SFT implementation defines a fungible token using define-fungible-token to allow for post conditions asserting the amount of tokens transferred. It enables the user to state "I will transfer exactly 50 semi-fungible tokens of contract A". I am still exploring options in which the user can also assert the type of token. There are definitely ways in which an NFT defined with define-non-fungible-token can be used.

Options I am considering:

MarvinJanssen commented 2 years ago

Adding for visibility:

@LNow suggested shorter keys for the print events:

Also probably we should keep these events as small as possible, so maybe we should replace sender and recipient in transfer event with from and to?

Re:

I like to stick with sender and recipient personally, as we see this terminology elsewhere too.

MarvinJanssen commented 2 years ago

That's right. Perhaps using UTF8 could save some space in comparison to punycode. What do you think?

Here's SIP009:

(get-token-uri (uint) (response (optional (string-ascii 256)) uint))

And here's SIP010:

(get-token-uri () (response (optional (string-utf8 256)) uint))
radicleart commented 2 years ago

I see and yes utf8 is neater than using punycode but would there need to be utf8 characters in the url or is this for future proofing?

saad-s commented 2 years ago

Here I created a game contract as a reference implementation, loosely based on Clash of Clans using this standard. I really like the idea of SFTs and the ease this provides in managing multi token apps

MarvinJanssen commented 2 years ago

Updated the SIP013 document based on feedback.

@fiftyeightandeight would appreciate your feedback since your project is heavily leveraging SIP013. Did you run into any issues or limitations? Also note that the trait has changed a little; namely, string-ascii for get-token-uri and a new limit of 100 instead of 200 for the send-many functions.

fiftyeightandeight commented 2 years ago

We have a number of contracts implementing SIP013 (an example of which is here). It helps maintain our code base compact (by aggregating similar contracts into a single contract), which is immensely helpful. The proposal currently implements transfer (with/out memo) slightly different from SIP010, which may be debated (I am in favour of the approach SIP013 is taking). It also has, necessarily perhaps, some compatibility issues with SIP010 (for example, get-balance vs. get-overall-balance), but I suppose this is more to do with wallet support, rather than the design of the proposal. It would be interesting if Clarity would ever support polymorphism, but I assume this is not going to happen.

MarvinJanssen commented 2 years ago

Thanks for the quick response @fiftyeightandeight, immensely insightful. Would you mind giving a high level explanation of how ALEX utilises this SIP and what the upsides are in a few sentences? I think it is a nice implementation that warrants some background for other curious developers.

fiftyeightandeight commented 2 years ago

Hi @MarvinJanssen , we at ALEX offer fixed-rate / fixed-term lending/borrowing. The rate of such lending/borrowing is determined collectively and dynamically by the ALEX community (by buying/selling the underlying contract). The term of such lending/borrowing is also determined collectively by the ALEX community, but is fixed at the time of such contract being created. For example, we may create a lending contract which expires on Jan 1, 2022 (i.e. fixed term), whose lending rate to expiry (i.e. fixed rate to expiry at a given point of time) is determined dynamically by the community.

From the Clarity contract perspective, this means you have a number of substantially similar contracts (e.g. lending contract) with slightly different configuration (i.e. expiry).

Before we adopted this SIP, we represented each of these contracts as a standalone contract, essentially copying and pasting an older contract, changing its name and updating it with a new expiry. You can see here what we had before we adopted this SIP. It is, put it mildly, not great.

After we adopted this SIP, we can aggregate all similar contracts (for example, yield-usda-xxxx.clar) into a single contract (for example, yield-usda.clar) without losing information about the expiry (in our case, token-id == expiry given its uniqueness). This immediately makes the code base much more compact while vastly reducing the margin of error. You can see how we now handle yield-token here.

MarvinJanssen commented 2 years ago

@fiftyeightandeight thanks for the explanation. Once (if) this SIP settles, I will update the reference repository with a list of interesting implementations like yours.

Zk2u commented 2 years ago

This is super cool. I'm working on Fractal - an NFT fractionalisation protocol; blog here, testnet v1.5 here. Could we make some changes to this standard around memos? I don't think we need two functions for with/without memo. Wasn't aware of this proposal before working on this, so v2.0 of Fractal would support this standard.

LNow commented 2 years ago

Just like you can transfer STX without providing memo, you should be able to do so with both FT and NFT, therefore I think separate functions should stay.

On a side note - @0xAsteria you have a huge security hole in your contract (unguarded trait + as-contract).

Zk2u commented 2 years ago

you have a huge security hole in your contract (unguarded trait + as-contract).

haha funny you pointed this out, I noticed it this morning when reworking a few things for 2.0. haven't worked out a fix though. any ideas there?

MarvinJanssen commented 2 years ago

As @LNow said (also, welcome back!), I also think it should stay as separate functions. Effectively, most token transfers do not include a memo and the naming also mirrors the upcoming Stacks 2.1 stx-transfer-memo? complement to stx-transfer?. However, if you have a compelling case we have not considered then feel free to share it.

Fractional NFTs are basically a type of SFT. I created two (untested) example contracts on how it can be done with SIP013:

  1. Wrapping SIP009, where the owner of the original NFT can split it into fractions that can only be recombined by owning all fractions. The original NFT is held by the SFT contract in the mean time.

https://github.com/MarvinJanssen/stx-semi-fungible-token/blob/main/contracts/examples/fractional-sip009-sft.clar

  1. A SIP013 SFT contract that takes the SIP009 out of the equation. If an NFT creator foresees that owners might in the future decide to split their NFTs, might as well have that functionality built-in. A SFT token-id/supply combination is effectively an NFT if the supply is 1. Thus, SFTs are initially minted with a supply of 1 in this case, and whoever owns the total supply of a particular token ID can (re)fractionalise it to any supply of his or her choosing.

https://github.com/MarvinJanssen/stx-semi-fungible-token/blob/main/contracts/examples/fractional-nft.clar

Was also going to point out the unguarded trait reference but @LNow beat me to it 😁.

LNow commented 2 years ago

you have a huge security hole in your contract (unguarded trait + as-contract).

haha funny you pointed this out, I noticed it this morning when reworking a few things for 2.0. haven't worked out a fix though. any ideas there?

Whitelisting or sandboxing. Whitelisting is easy to implement and used by most (if not all) NFT marketplaces build on Stacks. Sandboxing is more complicated (only tiny bit) and requires multiple contracts to be deployed, but at the end of the day it allows to build permission less systems. https://github.com/LNow/clarity-notes/blob/main/security/as-contract.md

LNow commented 2 years ago

@MarvinJanssen have you though about using both NFT and FT at the same time? NFT could be tied directly to type and would represent ownership of particular `type. And FT would represent amount.

Transferring X amount of Y type would result in:

That way user will see on the transaction confirmation screen that X units will be transferred out of user wallet, NFT Y will be transferred to contract and eventually returned.

MarvinJanssen commented 2 years ago

I assume your last sentence should read:

burning NFT if user transferred all or transferring NFT back to [contract]

Yes I thought of a few different ways of including an NFT—even introducing a more complex NFT type definition with a tuple. It would be really useful to be able to do post conditions but I couldn't come up with one that didn't have downsides or would clash in one situation or another. Can you share a bit more on what you think it could look like?

LNow commented 2 years ago

No. NFT is transferred back to user.

Main logic is the same as in your examples. FT represents amount, balance per type is stored in map. The only difference is that users receives unique NFT that represents ownership of one specific token type. And this NFT is not transferable.

For example user would have one NFT for gold, one for sword, one for helmet, one for viking helmet etc. ID of each NFT is unique. My sword NFT will have different ID then your sword NFT.

At first I thought that this idea is stupid and doesn't bring any value to what you proposed. But at some point in the future wallets might be able to display basic NFT metadata. And when that happen users will see exactly whether what they are transferring is exactly what they wanted to transfer or not.

In this PoC NFT is always returned back to sender, so it can be treated as mark that you have or had tokens of a specific type.

(define-constant CONTRACT_ADDRESS (as-contract tx-sender))

(define-fungible-token sft-value)
(define-non-fungible-token sft-token uint)

(define-constant ERR_INSUFFICIENT_BALANCE (err u1000))
(define-constant ERR_UNAUTHORIZED (err u1001))

(define-data-var lastNftId uint u0)

(define-map TypeSupply
    uint ;; TypeId
    uint ;; Supply
)

(define-map OwnerTypeToToken
    { owner: principal, typeId: uint }
    { tokenId: uint, balance: uint }
)

(define-read-only (get-balance (typeId uint) (who principal))
    (ok (get-balance-uint typeId who))
)

(define-read-only (get-balance-uint (typeId uint) (who principal))
    (get balance (default-to {balance: u0} (map-get? OwnerTypeToToken { owner: who, typeId: typeId})))
)

(define-read-only (get-overall-balance (who principal))
    (ok (ft-get-balance sft-value who))
)

(define-read-only (get-total-supply (typeId uint))
    (ok (get-total-supply-uint typeId))
)

(define-read-only (get-total-supply-uint (typeId uint))
    (default-to u0 (map-get? TypeSupply typeId))
)

(define-read-only (get-overall-supply)
    (ok (ft-get-supply sft-value))
)

(define-read-only (get-decimals (typeId uint))
    (ok u0)
)

(define-read-only (get-token-uri (typeId uint))
    (ok none)
)

(define-public (transfer (typeId uint) (amount uint) (sender principal) (recipient principal))
    (let
        (
            (tokenOwnedBySender (unwrap! (map-get? OwnerTypeToToken { owner: sender, typeId: typeId}) ERR_INSUFFICIENT_BALANCE))
            (tokenOwnedByRecipient (map-get? OwnerTypeToToken { owner: recipient, typeId: typeId}))
        )
        (asserts! (is-eq tx-sender sender) ERR_UNAUTHORIZED)
        (asserts! (>= (get balance tokenOwnedBySender) amount) ERR_INSUFFICIENT_BALANCE)

        ;; transfer NFT to contract
        (try! (nft-transfer? sft-token (get tokenId tokenOwnedBySender) sender CONTRACT_ADDRESS))

        ;; transfer FT to recipient
        (try! (ft-transfer? sft-value amount sender recipient))

        ;; update recipient balance and mint NFT if necessary
        (match tokenOwnedByRecipient token
            (map-set OwnerTypeToToken
                { owner: recipient, typeId: typeId }
                { tokenId: (get tokenId token), balance: (+ (get balance token) amount) }
            )            
            (let
                ((newNftId (+ (var-get lastNftId) u1)))
                (try! (nft-mint? sft-token newNftId recipient))
                (map-set OwnerTypeToToken
                    { owner: recipient, typeId: typeId }
                    { tokenId: newNftId, balance: amount }
                )
                (var-set lastNftId newNftId)
            )        
        )

        ;; update owner balance
        (map-set OwnerTypeToToken 
            { owner: sender, typeId: typeId }
            { tokenId: (get tokenId tokenOwnedBySender), balance: (- (get balance tokenOwnedBySender) amount) }
        )    

        (try! (as-contract (nft-transfer? sft-token (get tokenId tokenOwnedBySender) CONTRACT_ADDRESS sender)))

        (ok true)
    )
)

(define-public (transfer-memo (typeId uint) (amount uint) (sender principal) (recipient principal) (memo (buff 34)))
    (begin
        (try! (transfer typeId amount sender recipient))
        (print memo)
        (ok true)
    )
)

(define-public (mint (typeId uint) (amount uint) (recipient principal))
    (let
        (
            (tokenOwnedByRecipient (map-get? OwnerTypeToToken { owner: recipient, typeId: typeId}))
            (totalSupply (get-total-supply-uint typeId))
        )
        ;; not guarded by anything special, as this is only a PoC

        ;; update balance and mint NFT if necessary
        (match tokenOwnedByRecipient token
            (map-set OwnerTypeToToken
                { owner: recipient, typeId: typeId }
                { tokenId: (get tokenId token), balance: (+ (get balance token) amount) }
            )            
            (let
                ((newNftId (+ (var-get lastNftId) u1)))
                (try! (nft-mint? sft-token newNftId recipient))
                (map-set OwnerTypeToToken
                    { owner: recipient, typeId: typeId }
                    { tokenId: newNftId, balance: amount }
                )
                (var-set lastNftId newNftId)
            )        
        )
        ;; mint requested amount
        (try! (ft-mint? sft-value amount recipient))
        (map-set TypeSupply typeId (+ totalSupply amount))

        (ok true)
    )
)
MarvinJanssen commented 2 years ago

Right, and how would you know the internal token ID so you can construct your post condition? Read it first via a read-only call? Not insurmountable but seems inconvenient.

Are these roundtrip NFTs fine for post conditions? I assume so but never tried.

We could also define the NFT as this, perhaps:

(define-non-fungible-token sft-token {token-id: uint, owner: principal})

Then it is easier to determine what the token ID will be for post conditions. Every transfer would require the user to send that NFT to the contract which it will later get back. Or it could a burn-and-mint in one go, instead of a transfer. Also never tried how post conditions respond to that.

radicleart commented 2 years ago

Could the fractionalisation be done another way - e.g. using Judes nftree to fractionalise a single NFT file - in a similar way to how dropbox builds a merkle tree from blocks of a single file in order to make the download more efficient. Each chunk/block is already backed by an FT (tickets) equating to the value of that chunk w.r.t. the whole?

I think this in similar way to the Myceanas project which tokenised high value artworks to try to democratise artwork ownership?

LNow commented 2 years ago

We could also define the NFT as this, perhaps:

(define-non-fungible-token sft-token {token-id: uint, owner: principal})

Yes, good point. As this NFT is not a normal NFT that you can trade with other user it doesn't have to be compliant with SIP-009, therefore custom/complex ID should not be a big deal. And it eliminates extra read-only call from the workflow. :+1:

With regards to post-conditions - they will work perfectly fine.

MarvinJanssen commented 2 years ago

@LNow would it be that simple? https://github.com/MarvinJanssen/stx-semi-fungible-token/commit/309a9a9fb6be96543aec3ca36d5ccb0c6a32648e

I always naturally assumed something like this is meaningless because the final state doesn't change and that is what post conditions check. Nonetheless, I never tried it out. We could deploy something on testnet and play with it. (Side-note: the explorer really needs post condition support.)

LNow commented 2 years ago

Yes it is that simple.

I always naturally assumed something like this is meaningless because the final state doesn't change and that is what post conditions check.

Post conditions are very weird and they don't work exactly as they are described in the documentation.

Explorer, Wallet and documentation needs proper post-condition support.

MarvinJanssen commented 2 years ago

@LNow (& others) thoughts?

https://github.com/MarvinJanssen/stx-semi-fungible-token/pull/3

Will update the SIP doc in this PR in a bit.

LNow commented 2 years ago

@MarvinJanssen looks ok. To increase security it would be also good to change how wallet displays NFT post conditions. Right now NFT id is not displayed on TX confirmation screen. So every time you do something with NFT you can be tricked by UI to transfer/swap different (more expensive) token.

jcnelson commented 2 years ago

Thanks for sending this in @MarvinJanssen! Left feedback.

jcnelson commented 2 years ago

Did a second pass. I unresolved a couple comments because it helps me remember which ones need to be addressed before advancing this to Accepted status. Hope you don't mind ;)

MarvinJanssen commented 2 years ago

I unresolved a couple comments because it helps me remember which ones need to be addressed before advancing this to Accepted status. Hope you don't mind ;)

Sure, just makes it a little harder for me to figure out which ones I had already addressed but not yet published. 😁

MarvinJanssen commented 2 years ago

Happy to have been working on this since moving to the Clarity Lab.

Updated per discussions and comments above @jcnelson @friedger @radicleart @LNow.

jcnelson commented 2 years ago

Looks good to me. Feel free to transition this to Accepted status :)

MarvinJanssen commented 2 years ago

Great, done!

I do have a few more open points / questions:

  1. In the current version, Post Condition coverage sounds more advisory than mandatory. I am not sure if I prefer making the mechanisms in the reference implementation mandatory; specifically (a) the fungible token transfer to cover the amount, and (b) the burn-and-mint to cover the token ID. It would be good to get some more input and weigh the pros and cons. Also to add there is no good way to enforce these mechanisms. Traits obviously do not cover them and Clarinet would need something like https://github.com/hirosystems/clarinet/issues/268 to make such deep analysis possible.

  2. Suggestions on good Activation requirements that is clearly falsifiable. To me the standard seems "obvious" but is "N number of SIP013 token contract deployed by block height H" sufficient?

MarvinJanssen commented 2 years ago

@friedger @LNow what do you think regarding the above questions? Should we just move ahead with whatever feels right? Let's get this standard over the finish line.

friedger commented 2 years ago

1) Instead of enforcing post conditions, I would define a behavioural specification between the functions, like the balance function must return the same amount until transfer, transfer-memo was called. Tokens can be implemented with many native assets in various ways, therefore a specification for post conditions would not be too restrictive.

2) My concern would be that, currently, there is not a lot of demand for semi fungible tokens. Therefore, activation is not a question of number of deployed contracts. You could add that no alternative SIP draft was published after the deployment of a compliant contract and after so many blocks.

jcnelson commented 2 years ago

You could add that no alternative SIP draft was published after the deployment of a compliant contract and after so many blocks.

The risk here is that we pick a timeout that's too low, and then we're stuck with it. What if we did a combination?

We pick N, B, and C accordingly, where C is significantly bigger than B.

Also, I think N should be at least 1. I think that if no one implements this trait -- not even the SIP authors -- then there's no reason to have the SIP at all.

radicleart commented 2 years ago

Sounds good.

ALEX already have one implementation and Clarity Lab has another on testnet so N should be minimum 3.

fiftyeightandeight commented 2 years ago

Sounds good.

ALEX already have one implementation and Clarity Lab has another on testnet so N should be minimum 3.

Yes, our implementation is expected to go live later this month.

314159265359879 commented 2 years ago

You could add that no alternative SIP draft was published after the deployment of a compliant contract and after so many blocks.

The risk here is that we pick a timeout that's too low, and then we're stuck with it. What if we did a combination?

* There are N contracts by block B that implement this trait
* There are no revisions to this SIP's trait after C blocks

We pick N, B, and C accordingly, where C is significantly bigger than B.

Also, I think N should be at least 1. I think that if no one implements this trait -- not even the SIP authors -- then there's no reason to have the SIP at all.

I like this idea of Jude and Mike suggestion for N, minimum of 3.

Little history of SIP010 and SIP009 Block C should be a couple months in the future in my view. I see SIP010 (FT trait) was flagged as activation in progress on March 12th 2021, and the last revisions were made May 29th (changing the March 12th trait for the third/last time). Activation threshold (just N contracts deployed with trait) was met August 31st 2021. For SIP009 (NFT trait), activation was on March 17th 2021, last changes made on March 19th and it was activated on May 20th 2021.

Allowing 2-3 months for revisions, is that reasonable? In my view SIP010 (and SIP009) were special because they were made in a very hectic time when Stacks 2.0 was just launched and a lot was happening, this also lead to some senior developers noticing inconsistencies later in the process. Perhaps that is less likely to happen as we improve the SIP process. Suggestions/discussion for block B and block C:

Block B, (six months) 25920 BTC blocks after activation, or should this be closer to C? generally before or after C? Block C, three months after activation.

jcnelson commented 2 years ago

How are you all feeling about this SIP? Is it ready for the technical CAB to give it a formal review? This would mean that after sign-off, no substantial changes would be made without a withdraw/resubmit.

MarvinJanssen commented 2 years ago

I'm happy to move this forward. I think we can keep the post condition stuff as-is. However, we need to add activation criteria before we put it up for review. I'll sleep on it and submit the update tomorrow.

radicleart commented 2 years ago

@MarvinJanssen adding activation criteria is still outstanding.

MarvinJanssen commented 2 years ago

@radicleart updated Bitcoin block heights for activation and submitted.

rafaelcr commented 2 years ago

@MarvinJanssen is there a way for a caller to get the last token ID similar to SIP-009's last-token-id? If not, could it be added to this SIP? If I'm not mistaken, get-overall-supply would not give the caller the number of token classes but rather the sum of all token class supplies, right?

This would be very useful for token metadata indexing services so they could iterate through all the available token classes for a SIP contract

jcnelson commented 2 years ago

Thanks for adding the Activation section! Are you ready for a review from the technical CAB?

radicleart commented 2 years ago

This would be very useful for token metadata indexing services so they could iterate through all the available token classes for a SIP contract

@rafaelcr having last token count in SIP 13 standard doesn't make sense in the way it did for the SIP 009 standard. Individual SIP 13s represent a mapping from {token-id, owner} --> amount as opposed to the SIP 009 {token-id} --> owner mapping.

Instead of using the last token count concept you can try reading from the contracts mint events which are available via the stacks api

MarvinJanssen commented 2 years ago

Thanks for adding the Activation section! Are you ready for a review from the technical CAB?

Yes it is ready for review @jcnelson. Missed this one.

jcnelson commented 2 years ago

@kantai Can you take a look at this SIP when you get a free moment? Thanks!

MarvinJanssen commented 1 year ago

Pinging @kantai, is it good to move forward?

Hero-Gamer commented 1 year ago

Pinging @kantai, is it good to move forward?

Hi @MarvinJanssen on the weekly SIP call #17 was said that Aaron is out of action until January afaiu. On that call, Brice @obycode kindly volunteered to review this for you guys while Aaron is away. :)

obycode commented 1 year ago

Pinging @kantai, is it good to move forward?

Hi @MarvinJanssen on the weekly SIP call https://github.com/stacksgov/sips/issues/17 was said that Aaron is out of action until January afaiu. On that call, Brice @obycode kindly volunteered to review this for you guys while Aaron is away. :)

Looks good to me. It seems Aaron's concerns were addressed.

friedger commented 1 year ago

Let's move to ratified!

MarvinJanssen commented 1 year ago

It appears that all activation criteria have been met, so can we move straight to Ratified? @friedger @obycode?