sui icon indicating copy to clipboard operation
sui copied to clipboard

Relax the 'store' requirement for sui::dynamic_field::add, but enforce the same rules as polymorphic-transfer

Open PaulFidika opened this issue 3 years ago • 9 comments

Currently, NFT standards are trying to restrict the ability to transfer ownership of objects; not all creators will want their objects to be transferrable, or they will want their own custom transfer function.

(”Transfer” simply changes the Sui-defined owner from 0x123 -> 0x789.)

Any object with key + store can be transferred arbitrarily, using sui::transfer::transfer (polymorphic transfer). This is because if an object can be stored, you could create a custom object, store it, transfer it, and then unwrap it. Also, an object must have ‘key’ so it can be stored at the root-level of global storage using its UID (otherwise there is nothing to ‘transfer’).

Child objects (dynamic object fields) are currently required to have key + store.

The ‘store’ requirement could be relaxed, because the dynamic field is only storing an object-id, not the object itself. However, removing ‘store’ as a requirement alone would be a bad idea; in that case, any object with ‘key’ could be made a child object of an object with key + store; the parent object would then be transferred, the child object removed; we'd be back at polymorphic transfer again.

As such, I think rather than having sui::dynamic_field::add() as being the function people use to add a child to a parent, we should instead go back to using sui::transfer::transfer_to_object() (we can keep using sui::dynamic_field::add() if you prefer):

transfer_to_object will have the same restrictions that transfer::transfer does:

  • It can be used universally on any object with key + store
  • It can be used on any object with key, but only within the module that defines the object

Hence, creators can create an NFT that only has ‘key’ ability, and then can define their own custom logic by which that NFT can be (1) transferred to another person (transfer::transfer) or (2) transferred to another object (transfer::transfer_to_object()).

This won’t affect bag and table, because bag and table are restricted to only key + store objects anyway, and don't need permission from the defining module in order to transfer them in any way.

This will allow NFTs that cannot be polymorphic-transferred to still be used as children of parent objects (with the defining module's permission).

PaulFidika avatar Oct 26 '22 21:10 PaulFidika

(EDIT: I wanted to quickly note we discussed adding these rules for private transfer to dynamic_object_field. But we did not want things like object_bag or object_table to feel second class. After thinking about it a bit more, we have come to the view that the rules don't make much sense with dynamic fields... more on that below)


Child objects as previously considered no longer exist. From this point of view, the only thing you should really consider is being able to put an object inside of another object. In some sense, there are no more child objects, only wrapped objects.

Dynamic fields are a way of putting values inside of another object in a more traditional way, while Dynamic object fields are a special case that let you persist the "wrapped" in storage, making it easier for tools to track the wrapped object.

So if you have some asset type that you want to store inside of another object, it is being "wrapped" and it needs store. For this NFT case, I would think about this much like we think about Coin. The underlying asset has no rules for transference. It is just a simple asset/token. If you want to limited access to that asset, you "wrap" in another asset that controls the access.

Thinking about this in a real world analogy might help. If I have some collectible (like a special collectors coin, a stamp, or a funko pop), this is a tangible item I can hold or pass it around. I can pick it up from one box and move it to another. The item itself has limitations on how it is handled (outside of any physical limitations). If I want to apply a sense of ownership or limit access to the item, I might put it in a lock box. Or put it in my wallet. Or take it to a bank and put it into a safety deposit box.

Backing up, I would think about NFTs probably in the same way. The actual NFT is just a collectible. If you want to limit access (say you can't sell it for 30 days), you can wrap it in another object.

I know that this might require rewriting a lot of existing code, but happy to help if you get stuck in that process! And always listening if you have some pattern that you think does not fit well in the new system :)

EDIT EDIT: TLDR If you squint, there are no child objects anymore, only wrapping. So access control will probably have to done via a "wrapping" outer object. If that is really grotesque for some pattern/application you have, please share! These sort of direct example bits of feedback are very helpful!

tnowacki avatar Oct 26 '22 22:10 tnowacki

(EDIT: I wanted to quickly note we discussed adding these rules for private transfer to dynamic_object_field. But we did not want things like object_bag or object_table to feel second class. After thinking about it a bit more, we have come to the view that the rules don't make much sense with dynamic fields... more on that below)

Could you elaborate on that a bit more? I don't think object_bag or object_table will be subject to private transfer rules or their behavior will have to change, since all of their contents are key + store. I think, if we implement the private-transfer rules and allow key-only child-objects (as suggested above) those will still work the same.

Dynamic fields are a way of putting values inside of another object in a more traditional way, while Dynamic object fields are a special case that let you persist the "wrapped" in storage, making it easier for tools to track the wrapped object.

I don't really understand what you mean by 'persist... in storage'. When a child-object is added to a parent-object as a dynamic object field, it is fully-consumed and no longer exists in global storage; it can only ever be accessed via its parent object. The dynamic object field is essentially a more advanced version of wrapping, but otherwise works the same way.

Hmm; so wrap an NFT-type inside of an accessor type that determines the transfer-ability of the NFT. That's different from what I'm currently building, but I could explore that pattern too. Thanks for the suggestion.

PaulFidika avatar Oct 27 '22 00:10 PaulFidika

The issue is in my view that we are forced to: if we want to have a logical transfer of ownership (LToO) then such objects must be owned by an address. Unless I am missing something, there's no longer a way for such an object to be in an intermediate state. We cannot reconcile LToO with convenient trading contracts where the seller wouldn't have to sign another transaction to actually transfer the object.

This hinders some potential solutions for NFT creators who might want to enjoy more granular control over how are their NFTs transferred.

porkbrain avatar Oct 27 '22 10:10 porkbrain

Could you elaborate on that a bit more? I don't think object_bag or object_table will be subject to private transfer rules or their behavior will have to change, since all of their contents are key + store. I think, if we implement the private-transfer rules and allow key-only child-objects (as suggested above) those will still work the same.

What I am getting at is that dynamic_object_field is supposed to feel like a special case of bag. Not something distinct. You are correct that we could extend dynamic_object_field without touching the others, but we don't think it is necessary. It will complicate the APIs with what we think is little benefit. In other words, one set of consistent APIs helps make things easier to understand. As much as we can, we would like to avoid rules that aren't expressed in the type system.

I don't really understand what you mean by 'persist... in storage'. When a child-object is added to a parent-object as a dynamic object field, it is fully-consumed and no longer exists in global storage; it can only ever be accessed via its parent object. The dynamic object field is essentially a more advanced version of wrapping, but otherwise works the same way.

This is the difference between dynamic_fields and dynamic_object_fields. If you use the dynamic_field, the object is wrapped. It is stored inside of another struct. If you use dynamic_object_field, it only looks wrapped. The object can still be queried via the explorer or other tools. it still has a DB entry in other words, and isn't marked as wrapped/deleted.

tnowacki avatar Oct 27 '22 18:10 tnowacki

The issue is in my view that we are forced to: if we want to have a logical transfer of ownership (LToO) then such objects must be owned by an address. Unless I am missing something, there's no longer a way for such an object to be in an intermediate state. We cannot reconcile LToO with convenient trading contracts where the seller wouldn't have to sign another transaction to actually transfer the object.

This hinders some potential solutions for NFT creators who might want to enjoy more granular control over how are their NFTs transferred.

I'm not really sure I really understand what LToO is. Could you explain that in more detail? Or define it precisely?

But regardless, I'm not sure I understand the hinderance. What I was suggesting is that your NFT should behave just like a Coin, and that if you want custom transfer logic, you put it "inside" (it might not actually be wrapped in storage if you are using dynamic_object_field) of that other object, like you would with coin. Do you have some use case where you don't see that working well?

tnowacki avatar Oct 27 '22 21:10 tnowacki

Yeah, I’m afraid the change severely restricts the composability of key only objects. My understanding is that lots of use cases (and protocols) being built on Sui will rely on receiving objects as inputs and one way or another, will rely on shared objects. I suppose we can split these applications into the following broad use cases:

  1. Applications that receive objects as inputs, perform some logic and return those objects back to their owners (i.e. Modules that act as a medium for mutability i.e. staking a weapon in a game and allow its field damage to be mutated by a shared object; Modules that allow for some indirect form of ownership, like Borrow-Lending of an asset)
  2. Applications that receive objects as inputs, perform some logic and transfer those objects to addresses other than the owners (i.e. Trading Primitives; AMM Pools; Liquidity layers; Lotteries; etc.)
  3. Applications that receive objects as inputs and conditionally burn them
  4. Applications that perform some hybrid of the points above.

That being said, our perspective is that logical transfers tap into a substantially large and underserved market of Tiered Assets (objects with economic value and tier-based ownership rules). As an example: Most gaming assets are tiered in some form (e.g. A cape can only be used by a Wizard whilst a Sword can only be used by a Warrior; an item that can only be owned by a certain game player who has achieved a certain level of experience). There are legitimate use cases for these tiered gaming assets to be traded in tiered markets.

  1. In order to trade them, tiered assets must be put in the Safe object of the owner (defined in this RFC, please see also our current draft implementation here), allowing the owner to issue TransferCaps.
  2. To trade, the owner issues TransferCaps and transfers them to a shared object representing a market primitive, such as an Orderbook.

Here is where we start seeing some friction. If we substitute Asset has key with Lock<Asset: key + store> has key, where Lock is therefore responsible for the Logical Transfer of Ownership (LtoO), now we need to unwrap the underlying asset every-time we want to move that object to a shared one, which means we now rely on that shared object to assert that the right transfer logic occurs.

Back to the trading example, to add the asset to the Safe, we have to unwrap the Asset from its Lock. We see the following problems with this:

  1. Since we have lost the Lock, now either the Safe or all the trading primitives (i.e. Orderbooks, Auction Houses, Lotteries, Borrow-Lending, etc.) need to be aware of the transferability rules of such asset - and promise to honour those rules;
  2. How can we guarantee that the Lock<Assset> can only be safely unwrapped and transferred to shared objects that honour the transferability rules?
  3. This will most likely force the Asset creators to maintain some kind of whitelisting scheme, defining which shared objects can the Asset be transferred to;
  4. And whilst a whitelisting scheme is feasible for a global feature such as low-level royalty enforcement (because its maintenance can be delegated to the community and therefore be made self-managed if the Creator wishes to do so - we describe the approach here), it does not seem feasible for tier-based transferability rules because these are specific to each Asset type (and therefore we cannot expect the community to undertake the endeavour of maintaining whitelists specific to each Asset type).

Unless we are missing something, it seems that removing the ability for key only objects to be owned by shared objects considerably hinders the composability, and more generally the programmability of these kinds of Tiered Assets.

From what we see currently, we’re afraid that any solutions to this problem living within the boundaries of this new framework, will require most interactions between key only objects and shared objects to be made through some sort of intermediate object that does have key + store ability. This may likely result in more convoluted code logic and a more complicated mapping between business logic and technical logic. Not trying to be difficult here by the way - we’re just trying to offer our perspective.

You are correct that we could extend dynamic_object_field without touching the others, but we don't think it is necessary. It will complicate the APIs with what we think is little benefit. In other words, one set of consistent APIs helps make things easier to understand. As much as we can, we would like to avoid rules that aren't expressed in the type system.

We are therefore wondering if perhaps there is a goldilock solution to this where the new API implementation would still preserve the capability for key only objects to be transfered to other objects.

Please let us know if we are missing something, we would very much like to know what your thoughts are considering the above.

NMBoavida avatar Oct 28 '22 14:10 NMBoavida

@nmboavida, thanks for the detailed writeup!

How can we guarantee that the Lock<Assset> can only be safely unwrapped and transferred to shared objects that honour the transferability rules?

Could you walk me through this a bit more? How is it difficult for Lock<Asset> to guarantee this, but easy for just Asset to do it (in the scenario where we allow just key on dynamic_object_field)

tnowacki avatar Oct 28 '22 18:10 tnowacki

@nmboavida, thanks for the detailed writeup!

How can we guarantee that the Lock can only be safely unwrapped and transferred to shared objects that honour the transferability rules?

Could you walk me through this a bit more? How is it difficult for Lock<Asset> to guarantee this, but easy for just Asset to do it (in the scenario where we allow just key on dynamic_object_field)

To put it simply, what I'd really like is to make NFTs that are 'key' only. I don't want to add 'store', because 'store' allows other people to wrap an NFT in any arbitrary struct, making the NFT inaccessible. Furthermore, it enables polymorphic transfer, which is undesirable. Both cases take away control of the NFT from the NFT-defining module, and give it to other modules.

However, I would still like to be able to store an NFT inside of a dynamic_field that my module has control over (and is not publicly accessible to other modules unless I want it to be).

Is this doable?

PaulFidika avatar Oct 31 '22 05:10 PaulFidika

I'm not really sure I really understand what LToO is. Could you explain that in more detail? Or define it precisely?

Logical transfer of ownership is a property of an object such that its owner can only be changed within the defining module. That's why not having the store ability on children is so important to us.

Please have a look here: https://github.com/Origin-Byte/nft-protocol/pull/48/files#diff-34c6f41efa55d03336dda69bbe025184e2025c1871288838bd726b619a0e02ddR180

With children objects having key only, we could avoid NFT transfers that did not adhere to royalty collection. We could to some degree avoid off-chain trading. Our goal was to give the creators an option to be as lenient as they wanted to, creating a spectrum of royalty enforcement approaches. Additionally, nothing was hard coded and therefore the community could evolve the approaches to royalty enforcement, as well as detterents against bad actors.

What we are hoping for is the same utility that the original transfer_to_object function provided and the utility that dynamic objects provide.

What I was suggesting is that your NFT should behave just like a Coin, and that if you want custom transfer logic, you put it "inside" (it might not actually be wrapped in storage if you are using dynamic_object_field) of that other object, like you would with coin. Do you have some use case where you don't see that working well?

Coin custom transfer enforces module invariants (e.g. that no coins are created or destructed.) It does not support any logic to do with the object ownership. Since object ownership is a chain level abstraction, we need native APIs to interact with it. Coin pattern is not applicable to all use cases, and not to the specific one we have in mind.

porkbrain avatar Oct 31 '22 10:10 porkbrain

I'm closing this issue because it was rejected by Mysten and is no longer needed.

PaulFidika avatar Nov 10 '22 20:11 PaulFidika