Using Libraries as a Form of More Customizable Contracts

I've been thinking a lot about how we as a Solidity community can leverage our pre-existent knowledge on software design patterns.

One pattern I use quite a lot in non-Solidity programming is the Template Method. In short, it allows us to define a standard series of "steps" to run for a given algorithm, and then have different subclasses inherit and implement one of those steps differently.

We can imagine this in the example of an ERC721 that has different ways to get an index to mint. The naive implementation could implement all possible functions in the same contract, such as:

contract MintableERC721 is ERC721 {
    string public mintType;

    function mint() virtual public returns (uint) {
         if (mintType == "random")
              return mintRandom();
         else
             return mintSequential();
    }
}

This has a few issues:

  • All mintTypes must be defined before the launch of the contract
  • We have to waste contract storage on two implementations when only one will ever be called

A bit less naive solution would be to use a form of the Template Method, using an abstract contract as such:

abstract contract MintableERC721 is ERC721 {
    ....
    function mint() virtual public returns (uint) {}
}

Then, we could have two different implementations - one which selects an ID at random and another which uses a sequential id:

contract RandomIdMintableERC721 is MintableERC721 {
    ....
    function mint() public virtual overrides returns (uint) {
         uint tokenId = getRandomIndex()
          _transfer(msg.sender, tokenId);
        return tokenId;
    }
}


contract SequentialIdMintableERC721 is MintableERC721 {
    ....
    function mint() public virtual overrides returns (uint) {
          uint tokenId = tokens.current();
          _transfer(msg.sender, tokenId);
         return tokenId;
    }
}

This could work reasonably well, and we could imagine in our deploy code some conditional such as:

/**
* @param contractType - represents an implementation of `MintableERC721`, e.g.
* - SequentialIdMintableERC721
* - RandomIdMintableERC721
*/

function deploy = (contractType: string) => {
    
     const Contract: MintableERC721 = await ethers.getContractFactory(contractType);
     const contract = await contract.deploy()
     ....
}

Now imagine we want to implement many of these contracts, and imagine that the mint function is much heavier - we end up in a position where we are filling the blockchain with quite redundant code and wasting gas, which is good for no one.

I wonder if we could instead encapsulate the "overrideable" functionality (e.g. mint in the example above) into a library, modify the code to just operate on parameters (as opposed to the storage that we now wouldn't have access to), and then link that at compile time? This would have the advantage of saving gas, and would allow for any number more implementations in the future (which I suppose the second approach does as well).

// RandomTokenRetriever.sol
library TokenRetriever {
    struct StateStruct {
        ....
    }

    function getToken(StateStruct stateStruct) pure returns (uint) {}
}


// SequentialTokenRetriever.sol
library TokenRetriever {
    struct StateStruct {
        ....
    }

    function getToken(StateStruct stateStruct) pure returns (uint) {}
}

// MintableERC721.sol
import "./interfaces/ITokenRetriever.sol";

contract MintableERC721 is MintableERC721 {
    ....
    function mint() public virtual overrides returns (uint) {
          uint tokenId = ITokenRetriever.getToken(state);
          _mint(msg.sender, tokenId);
    }
}

Ultimately, I'd like to see if this is possible in conjunction with something like the pattern laid out in this discussion of UpgradeableProxies - where we could use one implementation that points to multiple different libraries to implement various functionalities, and pass in some variable to decide:

contract MyFactoryUUPS {
    address immutable tokenImplementation;

    mapping(bytes23 => address) mintingImplementations;

    event ContractDeployed(address contractAddress);

    constructor() {
        contractImplementation = address(new MyContractUpgradeable());
    }

    function createToken(bytes32 _mintingImplementation) external returns (address) {
       // todo: WHAT WOULD WE DO HERE TO DYNAMICALLY LINK?
        ERC1967Proxy proxy = new ERC1967Proxy(
            tokenImplementation,
            abi.encodeWithSelector(MyContractUpgradeable(address(0)).initialize.selector)
        );
        emit ContractDeployed(address(proxy));
        return address(proxy);
    }
}

NOTE: I realize hereafter that this kind of looks like a use case for the Diamond Pattern, but I find that that standard is too messy/overengineered for something like what the above is looking to achieve.

In writing that out, I realized we could just pass the _mintingImplementation to a constructor (well, in this case initializer) in MyContractUpgradeable, and then use it as an external contract, but is there a way to do this with the library linking? Any other considerations regarding gas/storage/upgradeability I am not be taking into account?

Would love to hear any thoughts or opinions!

Hey @0xLostArchitect! I like the idea of pushing the concept of libraries in Solidity a bit further. Here are some things that may be interesting to play with, based on what you shared:

  • Allow to dynamically choose between different libraries. In your minting example, I guess it would be something like this:

    interface Mintable { function mint(); }
    library RandomMintableLibrary is Mintable { .... }
    library SequentialMintableLibrary is Mintable { .... }
    
    contract ERC721 {
       function mint() {
         MintableLibrary library = type == "random" ? RandomMintableLibrary : SequentialMintableLibrary;
         library.mint();
       }
    }
    
  • Allow specifying the address of the library at runtime rather than statically linking it at compile time. This would allow upgradeability for changing the address of a library.

    contract ERC721 {
       MintableLibrary library;
       
       function upgradeMintable(MintableLibrary newLibrary) onlyOwner {
         library = newLibrary;
       }
    
       function mint() {
         library.mint();
       }
    }
    
  • Allow a library to access the contract's storage without having to define it as a struct. Maybe a library could accept this as the entire contract? Or define a set of storage variables that it expects?

Thanks for the response!

re the first proposal: that amount of hardcoding I don't believe would scale for X amount of implementations, and/or the code would be messy!

re the second: I like that idea! Although those libraries themselves can't be upgradeable, because libraries can't be upgraded, correct? So we would ultimately just deploy a second library and then could call upgradeMintable.

Regarding that pattern, is it best to just pass an address and then wrap that in an interface for the call, or would it be better to have it internally as you laid out? Are there implications there for cost, composability, extendability, etc?

1 Like