Skip to content

Conversation

willemneal
Copy link
Contributor

@willemneal willemneal commented Jul 9, 2025

This PR demonstrates how to make use the new contracttrait and contract_derive macros.

default_impl was a great first step at trying to get around the limitations of the contract_impl macro requiring the methods to be present preventing the ability to use default methods with a default implementation to allow for overwriting certain methods or just not requiring the boilerplate of each method in a given contract implementing a trait.

Comparison with #[default_impl]

Consider the enumerable NFT example:

#[default_impl]
#[contractimpl]
impl NonFungibleToken for ExampleContract {
    type ContractType = Enumerable;
}
#[default_impl]
#[contractimpl]
impl NonFungibleEnumerable for ExampleContract {}

#[default_impl]
#[contractimpl]
impl NonFungibleBurnable for ExampleContract {}
#[contracttrait]
impl NonFungibleToken for ExampleContract {
    type Impl =  Enumerable;
}

// or You can provide it as an argument

#[contracttrait(default = Enumerable)]
impl NonFungibleBurnable for ExampleContract {}

#[contracttrait]
impl NonFungibleEnumerable for ExampleContract {}
#[contractimpl]
impl NonFungibleToken for ExampleContract {
    type ContractType = Enumerable;
    fn balance(e: &Env, account: Address) -> u32 {
        Self::ContractType::balance(e, &account)
    }
  //....
}


#[contractimpl]
impl NonFungibleBurnable for ExampleContract {
    fn burn(e: &Env, from: Address, id: u32) {
        Self::ContractType::burn(e, &from, id);
    }
    // ...
}

#[contractimpl]
impl NonFungibleEnumerable for ExampleContract {
    fn total_supply(e: &soroban_sdk::Env) -> u32 {
        Enumerable::total_supply(e)
    }
    //...
}
impl NonFungibleToken for ExampleContract {
    type Impl =  Enumerable;
}

#[contractimpl]
impl ExampleContract {
     /// Returns the number of tokens owned by `account`.
    ///
    /// # Arguments
    ///
    /// * `account` - The address for which the balance is being queried.
    pub fn balance(e: &Env, account: Address) -> u32 {
        <Self as NonFungibleToken>::balance(e, account)
    }
    //..
}

impl NonFungibleBurnable for ExampleContract {
    type Impl = Enumerable;
}


#[contractimpl]
impl ExampleContract {
    /// Destroys the token with `token_id` from `from`.
    ///
    /// # Arguments
    ///
    /// * `from` - The account whose token is destroyed.
    /// * `token_id` - The identifier of the token to burn.
    ///
    /// # Errors
    ///
    /// * [`crate::non_fungible::NonFungibleTokenError::NonExistentToken`] -
    ///   When attempting to burn a token that does not exist.
    /// * [`crate::non_fungible::NonFungibleTokenError::IncorrectOwner`] - If
    ///   the current owner (before calling this function) is not `from`.
    ///
    /// # Events
    ///
    /// * topics - `["burn", from: Address]`
    /// * data - `[token_id: u32]`
    pub fn burn(e: &Env, from: Address, id: u32) {
        <Self as NonFungibleBurnable>::burn(e, &from, id);
    }
}


impl NonFungibleEnumerable for ExampleContract {
    type Impl = NonFungibleEnumerable!(); // returns ::contract_utils::Enumerable
}

#[contractimpl]
impl ExampleContract {
    /// Returns the total amount of tokens stored by the contract.
    pub fn total_supply(e: &soroban_sdk::Env) -> u32 {
        <Self as NonFungibleEnumerable>::total_supply(e)
    }
    //..
}

The big difference is that the the contract trait macro generates all methods and redirects back to the trait's method. Also since it generates it also copies in the documentation for each method which will show up in the contract metadata and thus the CLI and generated TS.

The other big advantage is being able to override what the default implementation is. Consider Enumerable, with default_impl, you are forced to use it. Where as with contracttrait you can override it with default = or just in the trait implementation. It also lifts ContractType for all traits to use through Impl.

Also since there is a separation between the implementation of the trait and the methods that get generated for the external contract_impl you can use &'s in the trait methods, which externally will be the value and then the referenced is passed to the contract's implementation of the trait.

Furthermore it allows for methods to be marked as internal and then they won't be exposed as part of the contractimpl . So for example, while internal_mint. can be an internal method of the FungibleToken trait. Then any default impl will need to implement it so that you can use Self::internal_mint instead of Base::mint. It is also very helpful for different auth checks. Again allowing you to swap out the implementation and still keep the internal interface. Thus all macros which insert checks now use Self and no longer pin to a specific function.

The biggest win though over default_impl is that you never need to update the macro. You change the definition of the trait and the generated code matches. Thus it is never the case that there is a mismatch between definitions making the code easier to maintain. And it makes adding new traits easy since you just write the trait.

Note

I had to update the macros because there was a change in the sdk version that caused an issue. Basically the macro's got added to a constant generated for the spec. I think it should be addressed upstream.

PR Checklist

  • Tests
  • Documentation

Copy link

netlify bot commented Jul 9, 2025

Deploy Preview for delightful-dieffenbachia-2097e0 canceled.

Name Link
🔨 Latest commit 54f3da6
🔍 Latest deploy log https://app.netlify.com/projects/delightful-dieffenbachia-2097e0/deploys/688d0d59f351c00008d05d79

@willemneal willemneal marked this pull request as draft July 9, 2025 03:08
@willemneal willemneal force-pushed the feat/contracttraits branch from 19c5321 to 6905c5c Compare July 14, 2025 17:03
@willemneal willemneal force-pushed the feat/contracttraits branch from 6905c5c to f29b74f Compare July 14, 2025 23:07
@ozgunozerk
Copy link
Collaborator

ozgunozerk commented Jul 17, 2025

@willemneal thanks a lot for this!
We gave it a thorough review, and here are our feedback/opinions on the subject:

Introducing macros is always a tricky subject, because we are effectively creating a DSL on top of Rust, which increases the entry barrier for new adopters.

Considering that, the macros introduced should have have the following properties:

  • the boilerplate it saves us should worth the complexity of the new DSL
  • if possible, it should be intuitive and the users shouldn't need to look-up the documentation to discover how they should use the macro (1-2 examples should be enough for them to adopt the macro)
  • again, if possible, the code it hides should be inspected and debugged easily with cargo expand command

For this PR, I don't think the criteria are met for the macros introduced. The reasons are:

  • the new DSL is much more complex than what we had in #[default_impl]
    • #[default_impl] is intuitive, because in Rust, developers are accustomed to not provide the default methods for the trait
    • the new DSL proposed here has its own structure and rules, which needs to be learned.
        #[derive_contract(
             AccessControl,
             FungibleToken(ext = FungibleAllowListExt),
             FungibleAllowList(default = ExampleContract),
             FungibleBurnable(ext = FungibleAllowListExt),
         )]
  • the new macro #[derive_contract] is working together with #[contract] macro, which is really bad in terms of expand and debug mechanisms. Because, it is unclear what exactly #[derive_contract] is doing, due to the expanded code is intertwined with the #[contract] macros output as well, which is HUGE (we are talking about 2k to 4k lines of code here)
  • on the contrary, #[default_impl] macro works really well with cargo expand command, and easy to see what's happening inside
  • the boilerplate that #[derive_contract] saves us, is not significant in our opinion.
    • for every item in #[derive_contract] macro -> correlates to 1 line:
      FungibleAllowList(default = ExampleContract),
    • we can remove 3 lines:
      #[default_impl]
      #[contractimpl]
      impl AccessControl for ExampleContract {}
    • means, we are only gaining 2 lines per an item in the #[derive_contract] macro

When we weigh in the complexity introduced by the new DSL, the "magic behind curtains" introduced by the macro, harder or impossible to debug the contract related code, we are skeptic on this approach.

As Leigh suggested in the discussion, if we can bring the utility of this macro without introducing a new DSL, that would be really great. However, I still think that is technically impossible due to how macros work in Rust.

Note:

  • type Impl = is a great workaround, and although it is yet another DSL to learn, it is tolerable imo

@willemneal willemneal force-pushed the feat/contracttraits branch from 87eafed to 2870630 Compare July 28, 2025 13:59

token_id
}

pub fn get_royalty_info(e: &Env, token_id: u32, sale_price: i128) -> (Address, i128) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unneeded since royalty_info is already exposed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants