OffchainLabs / stylus-sdk-rs

Rust Smart Contracts on Arbitrum
249 stars 81 forks source link

Experiment in composition #129

Open chrisco512 opened 5 months ago

chrisco512 commented 5 months ago

This is an attempt to outline a potential design for contracts that would allow more Rust-like patterns for polymorphism and encapsulation. In the present design of the SDK we provide some macros to imitate Solidity-style inheritance for library authors, but it breaks down for multiple inheritance. Instead of going down this route, we should embrace idiomatic Rust that provides for composition over inheritance.

See Rust Is Beyond Object-Oriented for a primer on this topic and how Rust differs from OOP.

Ideally we'd like library authors to be able to define various traits that align with ERC standards (such as IERC20) and allow for plugins or additional behavior to be defined as well (such as IERC20Burnable). They should also be able to provide a default storage schema and internal methods (that abstract most of the complexity) for developers to use for implementing those traits.

One of the problems in implementing Rust-style composition with the current SDK is our implementation of the Router trait (which is abstracted away from the user as an #[external] macro attached to an impl block for the #[entrypoint] struct). The Router trait is necessary for us to align with Solidity's ABI. At compile-time we use the #[external] macro to convert the methods in that block to Solidity-compatible 4-byte method selectors that we use to route requests to the proper method.

In order to support Rust-style composition and allow for multiple #[external] blocks of methods to be defined, we need to refactor the Router.

In the attached, you'll see a potential design for smart contract composition that could be enabled if this were enabled. It requires less generics, no PhantomData needed, no #[borrow] needed, and no #[inherit].

The erc20.rs crate exposes an ERC20 struct that contains the scoped storage fields for a stripped down ERC20 example. Attached to the struct are internal methods that abstract away the complexity for the implementation of the IERC20 trait, which contains all the methods that are part of the public spec.

Similarly, the ownable.rs crate exposes Ownable struct with attached internal methods and an IOwnable trait that defines the methods that must be implemented by a consumer contract.

In the lib.rs, you can see the top-level contract logic. The #[entrypoint] struct contains an erc20 field and an ownable field, which are typed as ERC20 and Ownable respectively.

We also implement the traits we want our contract to have, in this case IOwnable and IERC20, and we use the internal methods provided by Ownable and ERC20 to handle the logic unless customization is needed.

We additionally add a mint method to the MyToken struct itself. Here, we can see the composition at work, by using internal methods from both libraries to validate ownership before executing the internal mint method provided by ERC20.

Interested in thoughts as to the approach. I think it's much cleaner than what we currently have, while not being radically different either.