Today, let’s talk about how we used Rust’s trait system to provide a clean, safe, and intuitive developer experience (DevX) while porting Ethereum’s ERC standards to Stellar Soroban. It includes some trait juggling, which I enjoyed quite a bit whilst doing it. So I decided to share it with you.
The Problem: Conflicting Extensions in ERC721
When implementing ERC721 (NFTs) on Soroban, we encountered a delightful (read: infuriating) issue: some extensions conflict with each other. For example, Enumerable is fundamentally incompatible with Consecutive. On top of that, these extensions need to override methods like mint
, burn
, and transfer
, but Rust doesn’t have a built-in override mechanism like traditional OOP languages.
What We Need: Type Safety
Why do we need type safety? To prevent developers from implementing incompatible extensions, and let them know they’ve made questionable life choices at compile time.
What We Also Need: An Intuitive API
We should let developers define their NFT contracts naturally. If they have to import obscure intermediate traits, and decorate everything with generics, sure, we achieved type safety, but at what cost?
Enforcing Mutually Exclusive Traits
Our first goal for the end-developer experience looked something like this:
impl NonFungibleToken for MyContract {
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}
Unfortunately, Rust doesn’t support negative trait bounds, so we can’t write:
trait NonFungibleEnumerable: !NonFungibleConsecutive {}
Since we can’t use negative bounds, we flipped the problem: let’s use positive bounds instead. The idea is to enforce contract types explicitly.
trait NonFungibleToken {
type ContractType;
/* methods here */
}
We will have 3 Contract Types:
Base
: the base trait where we expect no override on the default behavior forNonFungibleToken
methods.Enumerable
: the extension that overrides some of theNonFungibleToken
methods.Consecutive
: another extension that overrides some other methods ofNonFungibleToken
.
Then, define extensions like this:
trait NonFungibleEnumerable: NonFungibleToken<ContractType = Enumerable> {}
trait NonFungibleConsecutive: NonFungibleToken<ContractType = Consecutive> {}
Now, when implementing NonFungibleToken, the developer must specify the contract type:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
}
Since Consecutive expects a different contract type, it cannot be implemented at the same time.
And voila! We achieved mutually exclusive traits:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}
Overriding Default Behavior for Different Contract Types
Avoiding Generics for Function Overrides
Another challenge: some functions (like transfer
) behave differently depending on whether Enumerable or Consecutive is implemented. One way to handle this is by adding generics:
fn transfer<T: ContractType>() {}
But generics here feel… off. The end-developer shouldn’t have to worry about passing transfer::<Enumerable>()
—it’s unintuitive.
Let’s break this down:
Generics Discussion
Note: The babbling below, is completely subjective, and you shouldn’t feel bad if you do not agree with me.
If a function like insert()
needs generics, I think it makes sense. Why? Because insert()
operates on a data structure, and we expect different behaviors based on whether it’s inserting into a Vec, HashSet, or HashMap. The implementation naturally varies depending on the structure.
But transfer()
? It moves a token from one account to another. The types of accounts and tokens don’t change whether the contract is Base, Enumerable, or Consecutive. The end-developer doesn’t expect transfer()
to depend on the contract type explicitly, even though we, as framework developers, know it does.
In short, generics make sense when a function’s behavior naturally varies based on its input types. But here, the developer’s mental model doesn’t align with reality—forcing them to specify transfer::<Enumerable>()
feels unnecessary.
So, we needed a way to override functions without generics.
Note: Babbling ends here. If you do not agree with me for the rest of the article, you should feel bad.
Trait-Based Solution
Behold! Trait-Based Function Dispatch!
We tweaked our NonFungibleToken trait:
trait NonFungibleToken {
type ContractType: ContractOverrides;
fn transfer(/* args */) {
Self::ContractType::transfer() // here is the default implementation,
// which relies on `ContractType`, and changing behavior based on the contract type
}
}
Here, ContractOverrides
trait provides all the methods that NonFungibleToken
has. If you are confused why do we have the same trait duplicated, you are right! I was also confused after I wrote this. And I WAS THE ONE WHO WROTE THIS.
Here’s where the magic happens: ContractOverrides
provides the actual default implementations. The Base
case (no extensions) looks like this:
pub trait ContractOverrides {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
Base::transfer(e, &from, &to, token_id);
}
}
where Base::transfer()
is the default behavior we expect for NonFungibleToken
trait implementation. And there is of course an impl
block for Base
struct.
pub struct Base;
impl Base {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
// do stuff
}
}
impl ContractOverrides for Base {
// this is EMPTY, because we do not need to override anything!
};
But Enumerable overrides this behavior:
pub struct Enumerable;
impl Enumerable {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
// do enumerable stuff here for overriding the `transfer()`.
}
}
impl ContractOverrides for Enumerable {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
Enumerable::transfer(e, &from, &to, token_id);
}
}
Now, when a contract specifies type ContractType = Enumerable;
, all the methods automatically get the right behavior. No need for the developer to manually override anything! And, of course, our mutually exclusive traits remain intact.
The Architecture
And now I can try to explain why did we need to have ContractOverrides
trait (which is essentially the duplicate of NonFungibleToken
trait).
Recall our goal:
impl NonFungibleToken for MyContract {
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}
We want to have each trait separate for the modularity. And we need to alter the behavior of NonFungbileToken
trait implementation, if there is NonFungibleEnumerable
trait implemented. This is WEIRD for Rust, but makes perfect sense in our scenario.
How do you make a trait alter it’s behavior based on the existence of another trait? You simply can’t!
But you can do another thing, you can have a helper type, on which your trait (NonFungibleToken
in this case) will change its behavior. And the other trait (NonFungibleEnumerable
) you will implement, can only be implemented for a specific helper type.
Now, our main trait NonFungibleToken
, cannot have direct default implementations, because we want them to be dynamic. So we need a second layer here, which is ContractOverrides
trait.
On a more structural/hierarchical level, it looks like this:
These are on the same level, and provide the actual default implementations:
ContractOverrides
forBase
ContractOverrides
forEnumerable
ContractOverrides
forConsecutive
And we have a super trait, NonFungibleToken
, takes one of these concrete types, and adjusts its behavior based on that very type, utilizing the ContractOverrides
trait.
In other words, NonFungibleToken
is just the wrapper trait, which uses ContractOverrides
trait and the associated type to adjust its behavior.
Result
You may be thinking: “all the trickery you’ve explained above, wouldn’t generics be easier instead of all that?”
You are right, generics would be easier!
And who knows, maybe in the future I’ll change my mind about this specific case. As I mentioned before, this is a subjective take. But I think even those who prefer generics over this trait juggling, would appreciate what we’ve accomplished with traits here.
The end-developer does not need to know any of the stuff I’ve explained in this post. The traits and associated types for sure made the library more complex for the maintainers, but observe how all this complexity is hidden from the end-developer:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
/* NO METHODS NEEDED! */
/* and necessary overrides already took place thanks to `Enumerable` type */
}
impl NonFungibleEnumerable for MyContract {
/* NO METHODS NEEDED! */
/* and if you like to override a method for your custom needs, you can */
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
// your stuff
}
}
// BELOW SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {}
and I think that is beautiful.