Skip to content

feat: contracttrait macro#1

Open
willemneal wants to merge 18 commits intomainfrom
feat/contracttrait_derive_contract
Open

feat: contracttrait macro#1
willemneal wants to merge 18 commits intomainfrom
feat/contracttrait_derive_contract

Conversation

@willemneal
Copy link
Copy Markdown
Member

@willemneal willemneal commented Jul 4, 2025

This PR introduces two macros to make contract's more composable. To motivate their use consider the following traits and how one would implement them to be easily added to a contract.

pub trait Adminstratable {
    fn admin(env: &Env) -> soroban_sdk::Address;
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address);
}

pub trait Upgradable {
    fn upgrade(env: &Env, wasm_hash: soroban_sdk::BytesN<32>);
}

Example implementation

First we need to provide some default implementations:

pub struct Upgrader;

impl Upgradable for Upgrader {
    fn upgrade(env: &Env, wasm_hash: soroban_sdk::BytesN<32>) {
        env.deployer().update_current_contract_wasm(wasm_hash);
    }
}

pub struct Admin;

impl Administratable for Admin {
    fn admin(env: &Env) -> soroban_sdk::Address {
        unsafe { get(env).unwrap_unchecked() }
    }
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address) {
        if let Some(address) = get(env) {
            address.require_auth();
        }
        env.storage().instance().set(&STORAGE_KEY, &new_admin);
    }
}

Next we need our contract struct which will expose the methods.

#[contract]
pub struct Contract;

#[contractimpl]
impl Administratable for Contract {
    fn admin(env: &Env) -> soroban_sdk::Address {
        Admin::admin(env)
    }
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address) {
        Admin::set_admin(env, new_account)
    }
}

#[contractimpl]
impl Upgradable for Contract {
    fn upgrade(env: &Env, wasm_hash: soroban_sdk::BytesN<32>) {
        Upgrader::upgrade(env, wasm_hash);
    }
}

As you can tell this is a lot of boilerplate if you wanted to add this functionality to your contract.

Associated type

One thing we can do to make this less boilerplate is make use of associated types and default trait methods. This way when defining the trait we can provide the default or base implementation which will be used.

pub trait Administratable {
    type Impl: Administratable
    fn admin(env: &Env) -> soroban_sdk::Address {
        Self::Impl::admin(env)
    }
    fn set_admin(env: &Env, new_admin: &soroban_sdk::Address) {
        Self::Impl::set_admin(env, new_admin)
    }
}

impl Administratable for Admin {
    type Impl = Self;
    ...
}
pub trait Upgradable {
    type Impl: Upgradable;

    fn upgrade(env: &Env, wasm_hash: soroban_sdk::BytesN<32>) {
        Self::Impl::upgrade(env, wasm_hash)
    }
}

impl Upgradable for Upgrader {
    type Impl = Self;
   ...
}

#[contractimpl]
impl Administatable for Contract {
    type Impl = Admin;
}
#[contractimpl]
impl Upgradable for Contract {
    type Impl = Upgrader;
}

However, we have a problem after we successfully compile this code. The generated contract will have no exposed methods, because the contractimpl macro can only generate the external methods for those.

So with this:

#[contractimpl]
impl Administratable for Contract {
    type Impl = Admin
    fn admin(env: &Env) -> soroban_sdk::Address {
        Self::Impl::admin(env)
    }
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address) {
        Self::Impl::set_admin(env, new_account)
    }
}

#[contractimpl]
impl Upgradable for Contract {
    type Impl = Upgrader;
    fn upgrade(env: &Env, wasm_hash: soroban_sdk::BytesN<32>) {
        Self::Impl::upgrade(env, wasm_hash);
    }
}

So without contractimpl being able to look up the definition of initial trait we are pretty much back where we started.

Introducing the contracttrait macro

The main idea behind contracttrait macro is that we can make it the trait's responsibility to generate the methods needed for contractimpl.

#[contracttrait(default = Admin)]
pub trait Adminstratable {
    fn admin(env: &Env) -> soroban_sdk::Address;
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address);
}

Will generate edit the trait to add default methods using an associated type and a declarative macro:

pub trait Administratable {
    type Impl: Administratable
    fn admin(env: &Env) -> soroban_sdk::Address {
        Self::Impl::admin(env)
    }
    fn set_admin(env: &Env, new_admin: &soroban_sdk::Address) {
        Self::Impl::set_admin(env, new_admin)
    }
}
#[macro_export]
macro_rules! Administratable {
    ($contract_name: ident) => {
        Administratable!($contract_name, $crate::Admin);
    };
    ($contract_name: ident, $impl_name: ident) => {
        #[soroban_sdk::contractimpl]
        impl $contract_name {
            pub fn admin_get(env: Env) -> soroban_sdk::Address {
                < $contract_name as Administratable >::admin_get(env)
            }

            pub fn admin_set(env: Env, new_admin: soroban_sdk::Address) {
                < $contract_name as Administratable >::admin_set(env, new_admin)
            }
        }
    };
    () => {
        $crate::Admin
    }
}

Interestingly, since declarative macro's are in a different namespace it can share the same name as the trait.

Then to use in our example:

Administratable!(Contract);
Upgradable!(Contract);

That's it!

It even lets you override the default implementation if you provide a second argument. The other benefit here is that we separate Contract's implementation of the trait from it's public interface which will come in handy later!

Trait Extensions

Not to be confused with extension traits, what I'm calling trait extensions are special types which let you combine or extend multiple implementations of a trait.

In the initial implementation,Upgradable's default didn't handle authentication at all.

Let's consider making an Administratable extension. This is not a run

pub struct AdministratableExt<T: Adiministatable, N>(
    pub PhantomData<T>, 
    pub PhantomData<N>);

Now we just need to make AdministratableExt Upgradable:

impl<T: Administratable, N: Upgradable> Upgradable for AdministratableExt<T, N> {
    type Impl = N;
    fn upgrade(env: &soroban_sdk::Env, wasm_hash: soroban_sdk::BytesN<32>) {
        T::admin(env).require_auth();
        Self::Impl::upgrade(env, wasm_hash);
    }
}

This makes use of blanket implementations in rust. It is saying for any type T which is Administratable and type N which is Upgradable implements Upgradable for AdministrtatableExt.

Then we can use this in place of the default.

impl Upgradable for Contract {
    type Impl = AdministratableExt<Contract, Upgrader>;
}

This means the upgrade will be called on the extension, which will use Contract's implementation of Administratable, to require that the admin provided authentication, then it will use Upgrader, which implements Upgradable, to call its version of upgrade.

To use it with the macro:

Upgradable!(Contract, AdministratableExt<Contract, Upgrader>);

While this might seem over complex it allows for more complicated composability. Take this example of a FungibleToken which is both Pausable and FungibleBlockList

FungibleToken!(Contract, FungibleBlockListExt<Contract, PausableExt<Contract, Base>>);

Using contracttrait when implementing trait

While the macros make it easier we can do better. You can also use contracttrait when implementing the trait on your contract.

#[contract]
pub struct Contract;

#[contracttrait]
impl Administratable  for Contract {}

#[contracttrait]
impl Upgradable for Upgrade {
    type Impl = AdministratableExt<Contract, Upgrader>;
}

Generates:

impl Administratable  for Contract {
    type Impl = Administratable!();
}

Administratable!(Contract, Contract);

impl Upgradable for Upgrade {
    type Impl = AdministratableExt<Contract, Upgrader>;
}

Upgradable!(Contract, Contract);

More options and compile time checks

contracttrait can also provide additional arguments:

default_required

Ensures that a default is provided.

#[contracttrait(default = Upgrader, default_required = true)]
pub trait Upgradable {
    fn upgrade(env: &soroban_sdk::Env, wasm_hash: soroban_sdk::BytesN<32>);
}

This ensures that the following will fail to compile:

#[contracttrait]
impl Upgradable for Upgrade {
    type Impl = AdministratableExt<Contract, Upgrader>;
}

#[internal]

Since there is a separation with what is exposed by #[contractimpl] and the trait implementation we can have some methods be internal only.

#[contracttrait(default = Admin, is_extension = true)]
pub trait Adminstratable {
    fn admin(env: &Env) -> soroban_sdk::Address;
    fn set_admin(env: &Env, new_admin: soroban_sdk::Address);
    #[internal]
    fn require_admin(env: &Env) {
        Self::admin(env).require_auth();
    }
}

Now you can use the internal method with in the contract for example with trait extensions:

impl<T: Administratable, N: Upgradable> Upgradable for AdministratableExt<T, N> {
    type Impl = N;
    fn upgrade(env: &soroban_sdk::Env, wasm_hash: soroban_sdk::BytesN<32>) {
        T::require_admin(env);
        Self::Impl::upgrade(env, wasm_hash);
    }
}

References in arguments

One limitation of the external interface generated by #[contractimpl] is that the arguments of an exposed method must pass by value (besides the required Env). E.g. fn set_admin(env: &Env, new_admin: soroban_sdk::Address)

Again we can take advantage of the separation of the trait interface and the external interface. When generating a trait method the external argument can be a value and then pass to the reference to the contract trait method.

#[contracttrait(default = Admin, is_extension = true)]
pub trait Adminstratable {
    fn admin(env: &Env) -> soroban_sdk::Address;
    fn set_admin(env: &Env, new_admin: &soroban_sdk::Address);
}

Generates a macro with a case:

        #[soroban_sdk::contractimpl]
        impl $contract_name {
            pub fn admin_get(env: Env) -> soroban_sdk::Address {
                < $contract_name as Administratable >::admin_get(env)
            }

            pub fn admin_set(env: Env, new_admin: soroban_sdk::Address) {
                < $contract_name as Administratable >::admin_set(env, &new_admin)
            }

This has one downside though and that is that the derived Contract struct will have two methods named admin_set which differ in their signature so in some cases this requires casting to remove ambiguity.

impl Contract {
    pub fn __constructor(env: &Env, admin: Address) {
        <Contract as Adminstratable>::admin_set(&admin);
    }
}

@willemneal willemneal force-pushed the feat/contracttrait_derive_contract branch from bd23c7d to 12315d0 Compare July 6, 2025 00:20
@willemneal willemneal force-pushed the feat/contracttrait_derive_contract branch from 12315d0 to 8ac4ff8 Compare July 6, 2025 00:20
@willemneal willemneal changed the title feat: add contracttrait and derive_contract macros feat: add contracttrait macro Jul 22, 2025
@willemneal willemneal changed the title feat: add contracttrait macro feat: contracttrait macro Jul 22, 2025
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.

1 participant