MystenLabs / sui

Sui, a next-generation smart contract platform with high throughput, low latency, and an asset-oriented programming model powered by the Move programming language
https://sui.io
Apache License 2.0
5.97k stars 11.09k forks source link

New 5th Struct Ability: Protected #7872

Open PaulFidika opened 1 year ago

PaulFidika commented 1 year ago

What if I wanted to create an asset that has both key and store; I like both abilities because key allows my asset to be a root-level asset that is easily accessible via entry functions, and because store gives me the flexibility to wrap my asset inside of another if I want to later on (such as putting it inside of a shared object).

But what if I also want to ensure that (1) only my own module is able transfer ownership (no polymorphic transfer), and (2) only my own module is able to store (wrap) the asset? That is to say, any Sui-ownership transfer and wrapping must be done from within the defining module itself. I think this can be accomplished by preventing this struct from being passed-by-value into functions outside of the defining module (and defining-module friends) itself. Without pass-by-value ability, it would be impossible for other functions to (1) wrap my asset, and (2) use polymorphic transfer with my asset (because sui::transfer::transfer is outside of the defining module).

I recognize that most assets in Move are built around composability, but this is more of a non-composability requirement. We could add a 5th struct ability, called protected (temporary suggested name) that enables this sort of protected behavior; it's more of an anti-ability than an ability, in the sense that it removes functionality rather than adding it (like the other abilities do), which makes composability the default setting for assets.

PaulFidika commented 1 year ago

Another way of implementing this would be to have store and protected-store as mutually exclusive abilities. store works as usual, but protected-store works as if store does not exist, except for when the call originates from within the defining module. Something like:

module 0x123::my_item {
   struct MyItem has key, protected-store {
      id: UID
   }

   // Valid call
   public entry fun store(my_item: MyItem, ctx: &mut TxContext) {
      external_module::store_item(my_item, ctx);
   }
}

module 0x456::external_module {
   struct Wrapper<T> {
      id: UID,
      inner: T
   }

   // Invalid if called directly with my_item::MyItem
   public entry fun<T: store>(anything: T) {
      let wrapper = Wrapper<T> {
          id: object::new(ctx),
          inner: anything
      };
      ...
   }
}

This call would be valid if made from within the defining module, but invalid if it originated from somewhere else. The defining module can wrap its own assets, or call out to other functions to wrap the asset for it, but the asset cannot be wrapped generally outside of these two cases (as is the case with regular store).

amnn commented 1 year ago

cc @tnowacki, this seems similar to the internal modifier we were discussing earlier.

PaulFidika commented 1 year ago

cc @tnowacki, this seems similar to the internal modifier we were discussing earlier.

Will the internal modifier be an ability, or how were you thinking of implementing it? Okay so the probability of this actually getting built sometime soon is pretty high? Okay that's awesome.

Because with the Capsules standard I'm working on, the flow would be kind of like:

I think this will be an elegant composable model that fully utilizes Sui's owned / shared object model. The problem is that without key + internal-store, we have to make the asset just store and then we have to place the object inside of some dummy wrapper object that prevents other modules from being able to get ahold of the object by value and wrapping it in their own object. But this inconvenient wrapper-object adds complexity (I love root-level objects more than wrapped objects--way easier to find and sue them) just in order to enforce creator-control.

tnowacki commented 1 year ago

Doing something like this is rather interesting.

Just for a bit of background on the idea around internal. internal would be a constraint or modifier to type parameters. Unlike abilities, you would not mark structs with it.

For example you could declare a function

public fun only_internal<T: internal>(x: T) { ... }

In this case only_internal could be instantiated one of two ways: A) A type that was defined in the current module. For example

module sui::example {
public fun only_internal<T: internal>(x: T) { ... }
}
}
module a::m {
struct MObj {...}
}
module b::t {
struct TObj { ...}
public fun example(m: a::m::MObj, t: TObj) {
  sui::example::only_internal<a::m::MObj>(m) // ERROR! current module b::t does not define MObj
  sui::example::only_internal<TObj>(t) // Valid! current module b::t _does_ define TObj
}

B) A bit more confusing, a generic that also has internal, e.g.

module other::another {
fun example<T, U: internal>(t: T, u: U) { 
  sui::example::only_internal<T>(t) // ERROR! current T does not have internal 
  sui::example::only_internal<U>(u) // Valid! current U _does_ have internal
}  

I think the suggestion of visibility on abilities with this sort of protected-store is interesting. Though I wonder if you can achieve the same thing with internal and then various wrapper types

PaulFidika commented 1 year ago

Oh okay, I see what you want internal to do; it's a further constraint on generic types used as function arguments. I imagine if Move had union-types you could express this behavior as like <T: this_module::StructA | this_module::StructB> which would be really different from how Move works now lol.

But yeah, for protected-store you can achieve the behavior by taking a struct, then storing it inside of a protective struct, storing that as a root-level (key) object, and then managing when / how it can be removed and placed inside of other structs. However, it would be a lot more ergonomic if developers could simply store their struct at root-level as a key object, and then wrap it inside of another struct only when they want to allow it to happen. That way you know your object is always either (1) at root-level with key, or (2) stored inside of XYZ struct that you allow within your module. You don't have to worry that someone will take your object and store it inside of some other weird ABC struct you've never heard of before.

tnowacki commented 1 year ago

Oh okay, I see what you want internal to do; it's a further constraint on generic types used as function arguments. I imagine if Move had union-types you could express this behavior as like <T: this_module::StructA | this_module::StructB> which would be really different from how Move works now lol.

Unions wouldn't solve this problem like this. It is not a restriction on what types can enter the function, but on where the types are used. It just wants to know that when the type parameter gets instantiated with a concrete type, that type was defined in the caller's module.

Maybe a better way of thinking of this is looking at sui::transfer::transfer

public fun transfer<T: key>(obj: T, recipient: address) 

Today, we have a custom verifier rule that says when calling transfer, T must either be (A) defined in the current module or (B) it must have store.

We could capture instead have two functions

public fun transfer<T: key + internal>(obj: T, recipient: address)  { ... }
public fun transfer_public<T: key + store>(obj: T, recipient: address) { ... }

the one with internal would be like (A) case, in order to instantiate T it has to be defined in the module at the call site. Not the definition site. This doesn't mean that only types from sui::transfer can use it. The (B) case would be covered by sui::transfer::transfer_public.

PaulFidika commented 1 year ago

Okay I get you; this internal behavior sounds rather different from what I was thinking for protected-store.

It sounds like Sui has special behavior built in for sui::transfer::transfer rules, and you're going to generalize this rule and make it more explicit, which is a good idea.

But they're both expanding upon the general idea of 'I want my module to have special privileges over this asset that it defines'.