The collection factory appeared out of necessity. We needed to programmatically deploy new contracts while allowing future API integration within the Sandbox ecosystem.
One of the first things we needed to decide was which way to go:
- One contract which houses several
ERC721collections. - An
ERC721contract for each collection but managed from a single point of operation.
We realized early on, due to painful past experiences, that having one contract address for several collections will lead to a plethora of issues down the road, so we scratched that right from the start. We went with the second option and started looking into proxy factories and management solutions.
Factory overview
The collection factory, composed of CollectionFactory and adjacent contracts, was designed with the KISS principle in mind while also taking into consideration solid security practices. We leveraged the beacon proxy pattern to achieve a one step upgrade to all grouped collections.
We also deployed our own custom proxy implementation to allow for that odd, but still valuable, case where a custom implementation is needed to fit into the puzzle.
The main contract itself is also not upgradable, what you see is what you get, but it allows a migration to take place in case it is ever required. We've been in this space long enough to know that you, well, never truly know and need a solid backup plan. Nobody wants to be in a war room.
The advantages of the CollectionFactory are:
- Deploy an
ERC721contract via one function call. - Upgrade grouped collections by one function call (contracts pointing to the same beacon).
- Change the implementation of a specific
ERC721contract by changing the beacon it points to. This can be done because of our customCollectionProxycontract - Light and simple (small attack surface).
- Non-upgradable but supports migrating collections to a new factory if ever needed.
- Written with security in mind and audited by OpenZeppelin with no medium or high issues found on the
CollectionFactoryandCollectionProxycontracts. - One collection per contract address. This makes 3rd party integrations easier.
- Open source and MIT licensed.
We experimented a bit with the Solady ERC1967Factory contract and Maple Finance's Proxy Factory but found they were not the best fit for us. Solady's ERC1967 factory was a good contender but at that time Solady still required a thorough audit (the Cantina audit was not performed yet). Unfortunately including any superfluous code into our scope could not be done due to strict time limitations.
Ultimately we settled on using a modified version of the Beacon proxy pattern based on OpenZeppelin's implementation.
Modified Beacon Proxy Pattern
The normal beacon proxy pattern allows multiple proxies to be upgraded to a different implementation in a single transaction. Consider the following diagram:

Each BeaconProxy contract holds the address of the UpgradableBeacon contract, and from that UpgradableBeacon it gets the address of the implementation to which it should point. BeaconProxy 1 to N are all deployed using the same UpgradableBeacon contract, as such they all point to the Implementation A contract. You can launch as many proxies as you want, cheaply, and get the same effect.
If you want to upgrade the underlying implementation of each proxy, all you need to do is change the address that UpgradableBeacon points to in a single transaction.
If you're interested in proxies in general we highly recommend you read OpenZeppelin's Proxies documentation, yAcademy's Proxies Research and banteg's Minimal Proxy Compendium.
With the normal beacon proxy, if you wish to change the implementation of one specific proxy you cannot do that without also upgrading all the other proxies that point to it. This presented a problem as our business logic required us to modify individual proxies (collections).
To resolve this issue we created the CollectionProxy, an extension of the normal BeaconProxy with the added feature of being able to change the UpgradableBeacon contract it points to. For example, if we wanted to change the implementation of BeaconProxy #3 (now CollectionProxy #3) to point to Implementation B, we simply change it to point to a different UpgradableBeacon #2, which points to this implementation.

Both the UpgradableBeacon contract and our custom CollectionProxy contract have a privileged admin role that is responsible for changing the addresses they point to.
We combined all the mentioned parts into one system where the CollectionFactory contract is the owner of every UpgradableBeacon and CollectionProxy contract.

Each avatar collection is a CollectionProxy contract. If there are no special requirements for that specific collection then it is launched with the default implementation. Otherwise, we deploy a new implementation, deploy an UpgradableBeacon contract pointing to it and then deploy the different collection proxy pointing to it (or change it to point to the new beacon, if it is already deployed).
The only step that is not done through the CollectionFactory contract is the deployment of a new implementation contract, which would not have made sense.
Technical description of the factory smart contracts
CollectionProxy
The CollectionProxy contract is an extension of OpenZeppelin's BeaconProxy implementation that supports having an admin (owner equivalent) who can change the beacon to which this proxy points. The initial admin is set to the deployer. [Source code here]
One thing to note is that it is generally a bad idea to add functions in the proxy itself, because any call to an underlying implementation function that has the same signature hash as a proxy function will actually be executed by the proxy and not the implementation. We know this and accepted the limitations that come with it. We thoroughly and obsessively documented this on the contract and on each function for increased dramatic effect.
/**
* @title CollectionProxy
* @author qed.team x The Sandbox
* @notice Beacon Proxy extension that supports having an admin (owner equivalent) that can
* change the beacon to which this proxy points to. Initial admin is set to the deployer
*
* @dev as there are several functions added directly in the proxy, any contract behind it (implementation)
* must be aware that functions with the following sighash will not be reached, as they will hit the
* proxy and not be delegate-called to the implementation
*
* Sighash | Function Signature
* =========================================
* f8ab7198 => changeBeacon(address,bytes)
* aac96d4b => changeCollectionProxyAdmin(address)
* 59659e90 => beacon()
* 3e47158c => proxyAdmin()
*
*/
Example:
/**
* @notice Changes the beacon to which this proxy points to
* @dev any function from implementation address with a signature hash collision of f8ab7198 will reroute here and cannot be executed
* If `data` is nonempty, it's used as data in a delegate call to the implementation returned by the beacon.
* Sighash | Function Signature
* =========================================
* f8ab7198 => changeBeacon(address,bytes)
* custom:event {ERC1967Upgrade.BeaconUpgraded}
* @param newBeacon the new beacon address for this proxy to point to
* @param data initialization data as an encodedWithSignature output; if exists will be called on the new implementation
*/
function changeBeacon(address newBeacon, bytes memory data) external
We also searched for any known sighashes on 4byte.directory that would clash with the ones we blocked, in order to warn users if there are any. At that time there weren't any other known hash collisions and only proxyAdmin and beacon were actually present in the 4byte directory database.
The extra added functionality revolves around an admin role that can change the beacon the proxy points to.
The following functions were added:
proxyAdmin— retrieves the admin.beacon— retrieves the beacon it points to.changeBeacon— changes the beacon it points to.changeCollectionProxyAdmin— changes the admin of the proxy (initial admin is contract deployer).
CollectionFactory
As quoted in the contract natspec, details about the CollectionFactory contract [Source code here] are:
- Its purpose is to allow for easy deployment of new collections and easy upgrade of existing ones.
- Factory can launch (or be added to) a beacon
UpgradeableBeaconto which collections may point. - Each collection is represented by a
CollectionProxythat points to a beacon. - Collections (proxies) can have the beacon they are pointing to changed.
Full deployment cycle
Here's an example that illustrates how the factory would be used to deploy a collection from the start. The team would initially deploy an implementation contract. The implementation must follow standard proxy-related security practices such as using initializers and having all functionality disabled in the contract constructor.
The CollectionFactory is deployed by the team. This step is of course needed only once.
CollectionFactory works with trusted beacons (UpgradeableBeacon contracts) and assigns aliases to them (byte32). To allow the factory to manage beacons there are two options:
- Deploy the beacon directly from the factory by providing an implementation address and valid alias, this is done by calling the
deployBeaconfunction.
/**
* @notice deploys a beacon with the provided implementation address and tracks it
* @dev {UpgradeableBeacon} checks that implementation is actually a contract
* @custom:event {BeaconAdded}
* @param implementation the beacon address to be added/tracked
* @param beaconAlias the beacon alias to be attributed to the newly deployed beacon
* @return beacon the newly added beacon address that was launched
*/
function deployBeacon(address implementation, bytes32 beaconAlias) external onlyOwner returns (address beacon) {
require(beaconAlias != 0, "CollectionFactory: beacon alias cannot be empty");
require(aliasToBeacon[beaconAlias] == address(0), "CollectionFactory: beacon alias already used");
beacon = address(new UpgradeableBeacon(implementation));
_saveBeacon(beacon, beaconAlias);
}
- Add an existing beacon to the factory, while also providing a valid alias. Beacon ownership must be given to the factory before the call, otherwise the operation reverts with
CollectionFactory: ownership must be given to factory. This is done by calling theaddBeaconfunction.
/**
* @notice adds, an already deployed beacon, to be tracked/used by the factory;
* Beacon ownership must be transferred to this contract beforehand
* @dev checks that implementation is actually a contract and not already added;
* will revert if beacon owner was not transferred to the factory beforehand
* @custom:event {BeaconAdded}
* @custom:event {CollectionAdded} for each collection
* @param beacon the beacon address to be added/tracked
* @param beaconAlias the beacon address to be added/tracked
*/
function addBeacon(address beacon, bytes32 beaconAlias) external onlyOwner {
require(beaconAlias != 0, "CollectionFactory: beacon alias cannot be empty");
require(aliasToBeacon[beaconAlias] == address(0), "CollectionFactory: beacon alias already used");
require(Address.isContract(beacon), "CollectionFactory: beacon is not a contract");
require(_isFactoryBeaconOwner(beacon), "CollectionFactory: ownership must be given to factory");
_saveBeacon(beacon, beaconAlias);
}
- Any new collection can now be deployed by calling
deployCollectionwith the alias of the beacon that will be paired with the collection and initialization arguments specific to the implementation. That's the entire initial setup process. Collections for which the available beacon/implementation are sufficient can be launched by callingdeployCollection.
/**
* @notice deploys a collection, making it point to the indicated beacon address
and calls any initialization function if initializationArgs is provided
* @dev checks that implementation is actually a contract and not already added
* @custom:event CollectionAdded
* @param beaconAlias alias for the beacon from which the collection will get its implementation
* @param initializationArgs (encodeWithSignature) initialization function with arguments
* to be called on newly deployed collection. If not provided,
* will not call any function
* @return collection the newly created collection address
*/
function deployCollection(bytes32 beaconAlias, bytes calldata initializationArgs)
public
onlyOwner
beaconIsAvailable(beaconAlias)
returns (address collection)
{
address beacon = aliasToBeacon[beaconAlias];
CollectionProxy collectionProxy = new CollectionProxy(beacon, initializationArgs);
collection = address(collectionProxy);
collections.add(collection);
collectionCount += 1;
emit CollectionAdded(beacon, collection);
}
Bulk collections updating
Upgrading collections is synonymous with changing the implementation address in a beacon and is done by calling the updateBeaconImplementation function.
/**
* @notice Changes the implementation pointed by the indicated beacon
* @dev {UpgradeableBeacon.upgradeTo} checks that implementation is actually a contract
* @custom:event {BeaconUpdated}
* @param beaconAlias alias for the beacon for which to change the implementation
* @param implementation the new implementation for the indicated beacon
*/
function updateBeaconImplementation(bytes32 beaconAlias, address implementation)
external
onlyOwner
beaconIsAvailable(beaconAlias)
{
UpgradeableBeacon beacon = UpgradeableBeacon(aliasToBeacon[beaconAlias]);
address oldImplementation = beacon.implementation();
beacon.upgradeTo(implementation);
emit BeaconUpdated(oldImplementation, implementation, beaconAlias, address(beacon));
}
Single collection update
To update the implementation of one specific collection, we need to have a beacon contract that points to this new implementation. Whether this beacon is a newly deployed one (via deployBeacon), an already existing one modified with updateBeaconImplementation, or a beacon added directly to the factory via addBeacon is irrelevant. The only requirement is for the beacon to be tracked by the factory.
Updating the collection is done by calling the updateCollection function.
/**
* @notice change what beacon the collection is pointing to. If updateArgs are provided,
* will also call the specified function
* @custom:event CollectionAdded
* @param collection the collection for which the beacon to be changed
* @param beaconAlias alias for the beacon to be used by the collection
* @param updateArgs (encodeWithSignature) update function with arguments to be called on
* the newly update collection. If not provided, will not call any function
*/
function updateCollection(
address collection,
bytes32 beaconAlias,
bytes memory updateArgs
) external beaconIsAvailable(beaconAlias) collectionExists(collection) onlyOwners(collection) {
address beacon = aliasToBeacon[beaconAlias];
CollectionProxy(payable(collection)).changeBeacon(beacon, updateArgs);
emit CollectionUpdated(collection, beaconAlias, beacon);
}
Adding collections to the factory
If collections need to be added to the factory that were not deployed by the current instance of the factory, we use the addCollections function.
This function has several key checks:
- The owner of the collection must be the factory.
- The owner of the beacon that this collection points to must also be the factory.
/**
* @notice adds collections to be tracked by the factory
* Collection ownership must be transferred to this contract beforehand
* @dev Reverts if:
* - no collections no were given, if the {collections_} list is empty
* - any of the give collections is 0 address
* - the collection owner is not the factory
* - failed to add the collection (duplicate present)
* - the owner of the beacon pointed by the proxy is not the factory
* @custom:event {CollectionAdded} for each collection
* @param _collections the collections to be added to the factory
*/
function addCollections(address[] memory _collections) external onlyOwner {
require(_collections.length != 0, "CollectionFactory: empty collection list");
uint256 collectionsLength = _collections.length;
collectionCount += collectionsLength;
bool success;
address beacon;
for (uint256 index; index < collectionsLength; ) {
address collectionAddress = _collections[index];
require(collectionAddress != address(0), "CollectionFactory: collection is zero address");
CollectionProxy collection = CollectionProxy(payable(collectionAddress));
require(collection.proxyAdmin() == address(this), "CollectionFactory: owner of collection must be factory");
success = collections.add(address(collection));
require(success, "CollectionFactory: failed to add collection");
beacon = collection.beacon();
require(_isFactoryBeaconOwner(beacon), "CollectionFactory: beacon ownership must be given to factory");
emit CollectionAdded(collection.beacon(), address(collection));
unchecked {++index;}
}
}
Migrating collections and beacons from the factory
In order to migrate the collections and beacons from the factory to a 3rd party, be it another factory or an EOA, there are 2 functions made to facilitate this:
transferBeacons— for removing beacon and transferring ownership to a new desired owner (only beacon, not underlying collections).
/**
* @notice Transfers a beacon from the factory. Sets the owner to the provided one.
* @custom:event {BeaconOwnershipChanged}
* @custom:event {BeaconRemoved}
* @param beaconAlias alias for the beacon to remove
* @param newBeaconOwner the new owner of the beacon. It will be changed to this before removal
*/
function transferBeacon(bytes32 beaconAlias, address newBeaconOwner)
external
onlyOwner
beaconIsAvailable(beaconAlias)
{
// "owner not zero address" check is done in UpgradeableBeacon::Ownable::transferOwnership
address beacon = aliasToBeacon[beaconAlias];
delete aliasToBeacon[beaconAlias];
beaconCount -= 1;
bool success = aliases.remove(beaconAlias);
require(success, "CollectionFactory: failed to remove alias");
UpgradeableBeacon(beacon).transferOwnership(address(newBeaconOwner));
emit BeaconOwnershipChanged(address(this), newBeaconOwner);
emit BeaconRemoved(beaconAlias, beacon, newBeaconOwner);
}
transferCollections— for removing collections and transferring ownership to a new desired owner.
/**
* @notice Transfers a list of collections from the factory. Sets the owner to the provided one.
* @dev will revert it a collection from the list is not tracked by the factory or if new owner is 0 address
* @custom:event {CollectionRemoved} for each removed collection
* @custom:event {CollectionProxyAdminChanged}
* @param _collections list of collections to transfer
* @param newCollectionOwner the new owner of the beacon. It will be changed to this before transfer
*/
function transferCollections(address[] calldata _collections, address newCollectionOwner) external onlyOwner {
// "owner not zero address" check done in CollectionProxy::changeCollectionProxyAdmin::_changeAdmin::_setAdmin
bool success;
uint256 collectionsLength = _collections.length;
for (uint256 index; index < collectionsLength; ) {
CollectionProxy collection = CollectionProxy(payable(_collections[index]));
success = collections.remove(address(collection));
require(success, "CollectionFactory: failed to remove collection");
emit CollectionRemoved(collection.beacon(), address(collection));
collection.changeCollectionProxyAdmin(newCollectionOwner);
emit CollectionProxyAdminChanged(address(this), newCollectionOwner);
unchecked {++index;}
}
collectionCount -= collectionsLength;
}
Conclusion
The CollectionFactory is a lightweight, efficient, simple and secure solution for scaling NFTs. We can deploy, update and manage hundreds of ERC721 contracts at the same time. We leveraged the Beacon Proxy Pattern and added our small extension to allow for a fully robust solution that handles all business cases in one way or another.
It can be used with any underlying implementations, it is not dependent on the Sandbox's concept of collection.
Resources
CollectionFactory[Source code here]CollectionProxy[Source code here]- OpenZeppelin [Read audit here]