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!