Security News
GitHub Removes Malicious Pull Requests Targeting Open Source Repositories
GitHub removed 27 malicious pull requests attempting to inject harmful code across multiple open source repositories, in another round of low-effort attacks.
@thirdweb-dev/dynamic-contracts
Advanced tools
Architectural pattern for writing dynamic smart contracts in Solidity
This repository provides core interfaces and preset implementations that:
This architecture builds upon the diamond pattern (EIP-2535). We've taken inspiration from it, and boiled it down to its leanest, simplest form.
forge install https://github.com/thirdweb-dev/dynamic-contracts
npm install @thirdweb-dev/dynamic-contracts
Router
contract can route function calls to any number of destination contractsExtensions
.Extensions
can be added/updated/removed at any time, according to a predefined set of rules.Router
- the entrypoint contractThe simplest way to write a Router
contract is to extend the preset BaseRouter
available in this repository.
import "lib/dynamic-contracts/src/presets/BaseRouter.sol";
The BaseRouter
contract comes with an API to add/update/remove extensions from the contract. It is an abstract contract, and expects its consumer to implement the _canSetExtension()
function, which specifies the conditions under which Extensions
can be added, updated or removed. The rest of the implementation is generic and usable for all purposes.
function _canSetExtension() internal view virtual returns (bool);
Here's a very simple example that allows only the original contract deployer to add/update/remove Extensions
.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "lib/dynamic-contracts/src/presets/BaseRouter.sol";
/// Example usage of `BaseRouter`, for demonstration only
contract SimpleRouter is BaseRouter {
address public deployer;
constructor(Extension[] memory _extensions) BaseRouter(_extensions) {
deployer = msg.sender;
}
/// @dev Returns whether extensions can be set in the given execution context.
function _canSetExtension() internal view virtual override returns (bool) {
return msg.sender == deployer;
}
}
The main decision as a Router
contract author is to decide the permission model to add/update/remove extensions. This repository offers some presets for a few possible permission models:
This a is a preset that allows the contract owner to add / upgrade / remove extensions. The contract owner can be changed. This is a very basic permission model, but enough for some use cases. You can expand on this and use a permission based model instead for example.
This is a preset you can use to create static contracts that cannot be updated or get new functionality. This still allows you to create modular contracts that go beyond the contract size limit, but guarantees that the original functionality cannot be altered. With this model, you would pass all the Extensions
for this contract at construction time, and guarantee that the functionality is immutable.
Other permissions models might include an explicit list of extensions that can be added or removed for example. The implementation is up to the Router author.
Extensions
- implementing routeable contractsAn Extension
contract is written like any other smart contract, expect that its state must be defined using a struct
within a library
and at a well defined storage location. This storage technique is known as storage structs. This is important to ensure that state defined in an Extension
doesn't conflict with the state of another Extension
of the same Router
at the same storage location.
Here's an example of a simple contract written as an Extension
contract:
/// library defining the data structure of our contract
library NumberStorage {
/// specify the storage location, needs to be unique
bytes32 public constant NUMBER_STORAGE_POSITION = keccak256("number.storage");
/// the state data struct
struct Data {
uint256 number;
}
/// state accessor, always use this to access the state data
function numberStorage() internal pure returns (Data storage numberData) {
bytes32 position = NUMBER_STORAGE_POSITION;
assembly {
numberData.slot := position
}
}
}
/// implementation of our contract's logic, notice the lack of local state
/// state is always accessed via the storage library defined above
contract Number {
function setNumber(uint256 _newNumber) external {
NumberStorage.Data storage data = NumberStorage.numberStorage();
data.number = _newNumber;
}
function getNumber() external view returns (uint256) {
NumberStorage.Data storage data = NumberStorage.numberStorage();
return data.number;
}
}
To compare, here is the same contract written in a regular way:
contract Number {
uint256 private number;
function setNumber(uint256 _newNumber) external {
number = _newNumber;
}
function getNumber() external view returns (uint256) {
return number;
}
}
The main difference is how the state is defined. While an Extension
written this way requires a bit more boilerplate to setup, it is a one time cost that ensures full modularity when using multiple Extension
contracts with a single Router
.
Router
Deploying a contract in the router pattern looks a little different from deploying a regular contract.
Deploy all your Extension
contracts first. You only need to do this once per Extension
. Deployed Extensions
can be re-used by many different Router
contracts.
Deploy your Router
contract that implements BaseRouter
.
Optionally, you pass your default Extensions
in the constructor of your BaseRouter
at deploy time. This is a convenient way to bootstrap an Router
with a set of default Extension
in one transaction.
Extensions
post deploymentThe preset BaseRouter
comes with an API to add/update/remove Extensions
at any time after deployment:
addExtension()
: function to add completely new Extension
to your Router
.updateExtension()
: function to update the address, metadata, or functions of an existing Extension
in your Router
.removeExtension()
: remove an existing Extension
from your Router
.The permission to modify Extensions
is encoded in your Router
and can have different conditions.
With this pattern, your contract is now dynamically updeatable, with granular control.
In the standard proxy pattern for smart contracts, a proxy smart contract calls a logic contract using delegateCall
. This allows proxies to keep a persistent state (storage and balance) while the code is delegated to the logic contract. (EIP-1967)
The pattern aims to solve for the following two limitations of this standard proxy pattern:
Note: The diamond pattern (EIP-2535) anticipates these same problems and more. We've taken inspiration from it, and boiled it down to its leanest, simplest form.
The router pattern eliminates these limitations performing a lookup for the implementation smart contract address associated with every incoming function call, and make a delegateCall
to that particular implementation.
This is different from the standard proxy pattern, where the proxy stores a single implementation smart contract address, and calls via delegateCall
this same implementation for every incoming function call.
Standard proxy pattern
contract StandardProxy {
address public constant implementation = 0xabc...;
fallback() external payable virtual {
_delegateCall(implementation);
}
}
Router pattern
abstract contract Router {
fallback() external payable virtual {
address implementation = getImplementationForFunction(msg.sig);
_delegateCall(implementation);
}
function getImplementationForFunction(bytes4 _functionSelector) public view virtual returns (address);
}
This setup in the Router
contract allows for different functions of the smart contract to be implemented in different logic contracts.
Extensions
- Grouping logical functionality togetherBy itself, the core Router
contract does not specify how to store or fetch appropriate implementation addresses for incoming function calls.
While the Router pattern allows to point to a different contract for each function, in practice functions are usually groupped by functionality related to a shared state (a read and a set function for example).
To make the pattern more practical, we created a generic BaseRouter
contract that makes it easy to have logical group of functions plugged in and out of it, each group of functions being implemented in a separate implementation contract. We refer to each such implementation contract as an extension.
BaseRouter
maintains a function_signature
→ implementation
mapping, and provides an API for updating that mapping. By updating the values stored in this map, functionality can be added to, removed from or updated in the smart contract.
Extension
to Extension
communicationWhen splitting logic between multiple Extensions
in a Router
, one might want to access data from one Extension
to another.
A simple way to do this is by casting the current contract address as the Extension
(ideally its interface) we're trying to call. This works from both a Router
or any of its Extensions
.
Here's an example of accessing a IPermission Extension
from another one:
/// in MyExtension.sol
modifier onlyAdmin(address _asset) {
/// we access our IPermission extension by casting our own address
IPermissions(address(this)).hasAdminRole(msg.sender);
}
Note that if we don't have a IPermission Extension
added to our Router
, this method will revert.
Extensions
Just like any upgradeable contract, there are limitations on how the data structure of the updated contract is modified. While the logic of a function can be updated safely, changing the data structure of a contract requires careful consideration.
A good rule of thumb to follow is:
Refer to this article for more information.
The best, most open way to give feedback/suggestions for the router pattern is to open a github issue.
Additionally, since thirdweb will be maintaining this repository, you can reach out to us at support@thirdweb.com or join our discord.
FAQs
Architectural pattern for writing dynamic smart contracts in Solidity
The npm package @thirdweb-dev/dynamic-contracts receives a total of 2,255 weekly downloads. As such, @thirdweb-dev/dynamic-contracts popularity was classified as popular.
We found that @thirdweb-dev/dynamic-contracts demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 8 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
GitHub removed 27 malicious pull requests attempting to inject harmful code across multiple open source repositories, in another round of low-effort attacks.
Security News
RubyGems.org has added a new "maintainer" role that allows for publishing new versions of gems. This new permission type is aimed at improving security for gem owners and the service overall.
Security News
Node.js will be enforcing stricter semver-major PR policies a month before major releases to enhance stability and ensure reliable release candidates.