FLIP: Interface inheritance in Cadence

Key Value
Status Proposed
Editor Supun Setunga (supun.setunga@dapperlabs.com)
Author Supun Setunga (supun.setunga@dapperlabs.com)
Originating PR https://github.com/onflow/flips/pull/40

Abstract

This proposal is to add support for interfaces to inherit other interfaces of the same kind. Currently, concrete type implementations (structs, resources, contracts) can conform to interfaces, but interfaces cannot conform-to/inherit other interfaces. What this means is, an author of an interface has no way to mandate the implementations to conform to another interface. The only way to enforce this at the moment is to duplicate all the fields, methods, and type requirements from the other interface to the new interface.

By allowing interface inheritance, developers would longer need to copy over methods, fields, or type requirements of other interfaces to their interface. This also increases the re-usability of interfaces, makes it easier for developers to structure their conformances, and reduces a lot of redundant code.

Added a new section describing the inheritance of type definitions and event definitions (Add a section for type definitions · onflow/flips@d98d658 · GitHub).
TL;DR is that they would also behaves similar to the default functions.

Replicating my comment on the PR here, regarding the potential for downstream code to break when a interface is updated to have a new default implementation.

I would argue worse even than breaking downstream code is the potential to change what a piece of code is doing at runtime by changing an interface inheritance chain. Given some chain A -> B -> C where A provides a default implementation of foo , B is an interface conforming to A , and C is an concrete composite conforming to B , if B adds an overriding implementation to foo , this would cause A 's implementation to no longer be used by C . This means that the author of B 's contract can change the behavior of C , even though it may not be the author of C 's contract. This feels like a big safety concern to me, to the point where I feel we should simply disallow overriding default implementations entirely in order to avoid it.

Like I mentioned in the PR, I would say, that’s a kind of ‘trust’ a developer would opt in when conforming to an interface - without overriding. That could happen even now, with no inheritance, where the author of the interface can change the default implementation later, which would change the concrete type’s behaviour.

On the same lines, say if A didn’t have a default implementation for foo, but instead B had a default implementation to begin with, and later A also decide to add a one. Then this would cause the downstream codes to break. I feel this could hurt the composability a lot.

For this reason I would argue we should limit default implementations to exist only on the interface that actually defined the method in the first place; i.e. in your example B would be unable to add a default implementation for foo, since A was the original interface that declared foo, and thus only A could add a default for it (obviously any concrete composite implementing A could still implement it).

I feel that doesn’t really solve it, but rather kind of shifts the problem to the different place. For e.g: what if A didn’t really had foo at first and decided that anyone implementing A should also have foo, and hence added the signature/default function to A. Then initially B was the originator of foo, but not any longer. These are the kinds of use-cases people would hope to solve with default functions, and restricting them would limit the usages/value of having default functions.

This is already an issue regardless though: B may add a new function to foo in addition to conforming to A, but A may choose to add foo with a different type signature, which will then break B.

Yes, thats true. Idea is to support as much cases as possible.
Generally if the signature is different, then they are two different functions, so anyway that should be incorrect to have them both. i.e: no overloading

I’m fully in support of this! I didn’t realize there would be so many considerations for default implementations, but it makes sense. I’m not too concerned about updates to interfaces causing changes in downstream dependencies because primarily I don’t think default implementations will be very common, but also, the fact that we even allow default implementations in the first place introduces that consideration, which we have already decided is worthwhile. If a developer is worried about a default implementation changing, they can add an implementation for it themselves to override that.

One thing that I believe would help with making it clearer to developers which interfaces they are inheriting would be for the concrete type that implements the interface to be required to explicitly declare that they are implementing all the interfaces that are in the chain of inheritance, so like in your example, if MyVault implements Vault which inherits from the Receiver interface, I think it would be good for Vault to declare Receiver in its list of interfaces instead of it being implicit:

pub resource interface Receiver { 
    pub fun deposit(_ something: @AnyResource) 
} 

pub resource interface Vault: Receiver { 
    pub fun withdraw(_ amount: Int): @Vault 
}

// Need to include Receiver here
pub resource MyVault: Vault, Receiver { 
    pub fun withdraw(_ amount: Int): @Vault {} 
    pub fun deposit(_ something: @AnyResource) {} 
}

This way, none of the interfaces you are implementing are hidden from you.

I was the defender of this long time, but now I have some doubts to be honest. Especially when conflict occurs on the interface.

Repeating my comment from GitHub ( which is a wonderful place actually for FLIP discussions btw ), I think this is changing the trust model from interface - contract ( one on one ) to a web of trust, (one to many )

Now I have to blindly trust all the interfaces the interface I am using trusted. As we lack versioning of contracts I think this is a big problem with compatibility and security hole. ( Imagine npm with always latest version of the packages )

We need to somehow flatten the model, but I don’t know how to do that. @flowjosh 's suggestion seems really good in my opinion. But I still have doubt on that, what will happen if Vault decides to add Provider interface such as : pub resource interface Vault: Receiver, Provider ?

On contract upgrade limitations, we have :

Removing an interface conformance of a struct/resource is not valid [0]

  • What will happen when Provider in the example adds some incompatible change ? ( I know this is also currently a problem, but we will run into this more with this new proposal )

One solution comes to mind is to allow partial interface implementations, but it just came into my mind, I didn’t think it over much. But main entry point is, interfaces are actually pre/post conditions ( from security pov, even though most contracts are not designed that in mind ) I get guarantee that, when something implements that interface, that methods will do what they say.

Considering something like (bear with me) :

pub resource interface SomeInterface: A, B{
}

pub resource interface A  { 
    pub fun getSomeValue(): UFix64 {
      pre/post here
    }
}

pub resource interface B  { 
    pub fun getName(): String {
      pre/post here
    }
}

if B adds pub fun getSomeValue(): String we have a problem of conflict. Actually I would say pub fun getSomeValue(): UFix64 is even problematic, cause it can have totally different meaning. ( but it is deeper subject )

So maybe in that case, we can allow partial implementation, if I use variable as “@AnyResource{A}” I am bound to A interface’s pre/post conditions only. if I use as “@AnyResource{SomeInterface}” both.

If there is conflict, I get static or runtime error, when trying to use with SomeInterface, but I still can use B’s getName even it added pub fun getSomeValue(): String my contract does not implements. ( hence the partial implementation )

So basically I signal which function pre/post conditions to use, when I implement a function.

pub resource Vault: SomeInterface { // Vault: A, B
 //this comes from interface A 
 pub fun getSomeValue(): UFix64 {
     pre/post here
 }
 //this comes from interface B
 pub fun getName(): String {
     pre/post here
   }

  //missing implemenatation from B:  getSomeValue(): String 
}
  • Vault.getSomeValue() → runtime error conflict
  • Vault.getName() → B’s pre/post
  • (Vault as @{B}).getName() → works, B’s pre/post
  • (Vault as @{A}).getSomeValue() → works, A’s pre/post , returns UFix64
  • (Vault as @{B}).getSomeValue() → runtime error, partial implementation error

This avoids common conflicts, if I store Vault as a field typed @{A} whatever B ( or SomeInterface ) adds doesn’t break me, automatically.

But this still leaves the problem of default interface implementations. Leaving as a quick note here:

pub resource Vault: SomeInterface

Imagine A as Receiver, B as Metadata interface ( which is evil )

if Metadata interface adds a function withdraw with default implementation to empty Vault. There is not much we can do. ( here is the problem of web of trust ) One solution can be to force SomeInterface to declare all methods from the interface B, and allow only those.

pub resource interface SomeInterface: A, B{
    pub fun getSomeValue(): UFix64 
    pub fun getName(): String 
}

here getSomeValue and getName will only move over. Even we can be more descriptive:

pub resource interface SomeInterface : partial A, partial B{
        pub fun A.getSomeValue()
        pub fun B.getName(): String    
}

[0] Contract Updatability | Cadence

1 Like

@bluesign I like how powerful your suggested solution is, but if I remember correctly, we had proposed a similar solution for attachments previously, and there were some sentiments against it and decided to not to go on that path. Maybe @sainati can provide more details on that.

I was on the camp who voted for that idea, but I too feel it could lead to lot of questions/confusions for developers in this particular scenario, as to why they have to cast to a particular type when calling a function, why calling a certain method result in an error, etc. Here we are also sort of allowing method overloading (not directly, but in a way).

Yes, this was originally how we intended to handle multiple extensions when two or more extensions had conflicting functions; we would require the static “view” of the composite that had been extended to be upcast to the limited type that only contained one of the two extensions. We ultimately ended up not pursuing this because of the reasons Supun mentioned, but also because if someone added a method to their extension it would could cause a bunch of code to break downstream and require manual casting.

If we really think that the conflicts can cause problems (specially with default implementations), we can restrict the conflicts by:

Option I: disallowing any kind of conflicts, only the originator can add default implementation and/or conditions (like @sainati mentioned)

Option II: only allow one default implementation / one condition, for the entire chain. Doesn’t matter whether its the originator or someone in the middle of the chain

Option III: I really think having conditions from multiple interfaces for the same method is harmless. So I would go a step further and say: Having conflicting methods is fine, as long as they don’t have default implementations. Also means, can have conditions from multiple interfaces in the chain, but strictly one default implementation for the chain.

I think situation of conflict ( also in current cadence ) and how to handle it without invalidating type is important.

Considering we want people to use interfaces for composability, define some basic rules like:

  • upstream can never break my type ( as we will have a whole dependency graph )
  • I don’t need to trust other than my immediate dependency
  • I can decide to trust individual interface ( somehow mark it as trusted )

then building something around those rules can be beneficial.

disallowing any kind of conflicts, only the originator can add default implementation and/or conditions

How this can work ? if my interface X implements FT.Receiver, if I can remove FT.Receiver post/pre, it is not FT.Receiver anymore. Only guarantee we have from FT.Receiver is, executing FT.Receiver post/pre. Do you mean it will not be FT.Receiver anymore?

only allow one default implementation / one condition, for the entire chain. Doesn’t matter whether its the originator or someone in the middle of the chain

This can be the first pre/post introduced. But then interface I am implementing adds a post condition, mine would be invalid, even if will be seen in the code. ( Unless we invalidate interface type, this is confusing )

really think having conditions from multiple interfaces for the same method is harmless.

Can you expand on this?

No, here only the FT.Receiver can have pre/post conditions. X cannot add any new conditions.

Yes, correct. this will now be a static error.

This is basically the original proposal of the FLIP (with a strict rule for default implementations).

pub resource interface Receiver  { 
    pub fun deposit(_ something: @AnyResource) {
        pre{ self.balance < 100 }
    }
}

pub resource interface Vault: Receiver {
    pub fun deposit(_ something: @AnyResource) {
        pre{ self.balance > 50 }
    }
}

Here anyone implementing Vault would be restrained by both conditions. i.e: 50 < self.balance < 100

BTW, we can always start with a more strict set of rules for the initial version, and gradually relax the rules (that can be a new proposal).

Maybe we can limit inheritance depth ? For example:

pub resource interface Receiver{}
pub resource interface Provider{}
pub resource interface Vault: Receiver, Provider {}

// we can disallow this one 
pub resource interface SuperVault: Vault, MetadataProvider {}

Although it is a bit problematic too.

My fear is interface changes breaking downstream types, as some projects can be abandoned etc, breaking type is very dangerous. ( We have this problem already with current interfaces though )

@supun Can we have another working group meeting about this soon? We would like to have this feature for the v2 token standards in stable cadence

@flowjosh Yeah definitely! We, unfortunately, had to prioritize some of the other FLIPs over this, given the bandwidth of the team and everyone involved in the discussions. But we haven’t lost the radar on this, and planning to pick right back up as soon as some of the other FLIPs come to a closure.

Will try to schedule something in the coming weeks.