Upgrading your plugin
Updating an Upgradeable plugin means we want to direct the implementation of our functionality to a new build, rather than the existing one.
In this tutorial, we will go through how to update the version of an Upgradeable plugin and each component needed.
You can skip this section if you are working on a non-upgradeable plugin. |
How to create new builds to an Upgradeable Plugin
A build is a new implementation of your Upgradeable Plugin. Upgradeable contracts offer advantages because you can cheaply change or fix the logic of your contract without losing the storage of your contract.
The Aragon OSx protocol has an on-chain versioning system built-in, which distinguishes between releases and builds.
-
Releases contain breaking changes, which are incompatible with preexisting installations. Updates to a different release are not possible. Instead, you must install the new plugin release and uninstall the old one.
-
Builds are minor/patch versions within a release, and they are meant for compatible upgrades only (adding a feature or fixing a bug without changing anything else).
In this section, we’ll go through how we can create these builds for our plugins. Specifically, we’ll showcase two specific types of builds - one that modifies the storage of the plugins, another one which modifies its bytecode. Both are possible and can be implemented within the same build implementation as well.
Before moving on, make sure you have at least one build already deployed and published into the Aragon protocol, you can check out our publishing guide to ensure this step is done.
Create a new build implementation modifying storage
In this second build implementation we want to update the functionality of our plugin - in this case, we want to update
the storage of our plugin with new values. Specifically, we will add a second storage variable address public account
.
Additional to the initializeFromBuild2
function, we also want to add a second setter function storeAccount
that uses
the same permission as storeNumber
.
As you can see, we’re still inheriting from the PluginUUPSUpgradeable
contract and simply overriding some implementation
from the previous build. The idea is that when someone upgrades the plugin and calls on these functions, they’ll use this
new upgraded implementation, rather than the older one.
import {PluginUUPSUpgradeable, IDAO} '@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol';
/// @title SimpleStorage build 2
contract SimpleStorageBuild2 is PluginUUPSUpgradeable {
bytes32 public constant STORE_PERMISSION_ID = keccak256('STORE_PERMISSION');
uint256 public number; // added in build 1
address public account; // added in build 2
/// @notice Initializes the plugin when build 2 is installed.
function initializeBuild2(
IDAO _dao,
uint256 _number,
address _account
) external reinitializer(2) {
__PluginUUPSUpgradeable_init(_dao);
number = _number;
account = _account;
}
/// @notice Initializes the plugin when the update from build 1 to build 2 is applied.
/// @dev The initialization of `SimpleStorageBuild1` has already happened.
function initializeFromBuild1(address _account) external reinitializer(2) {
account = _account;
}
function storeNumber(uint256 _number) external auth(STORE_PERMISSION_ID) {
number = _number;
}
function storeAccount(address _account) external auth(STORE_PERMISSION_ID) {
account = _account;
}
}
Builds that you publish don’t necessarily need to introduce new storage variables of your contracts and don’t necessarily need to change the storage. To read more about Upgradeability, check out OpenZeppelin’s UUPSUpgradeability implementation here.
Note that because these contracts are Upgradeable, keeping storage gaps uint256 [50] __gap; in dependencies is a must in
order to avoid storage corruption. To learn more about storage gaps, review OpenZeppelin’s documentation here.
|
Create a build implementation modifying bytecode
Updates for your contracts don’t necessarily need to affect the storage, they can also modify the plugin’s bytecode. Modifying the contract’s bytecode, means making changes to:
-
functions
-
constants
-
immutables
-
events
-
errors
For this third build, then, we want to change the bytecode of our implementation as an example, so we 've introduced two
separate permissions for the storeNumber
and storeAccount
functions and named them STORE_NUMBER_PERMISSION_ID
and STORE_ACCOUNT_PERMISSION_ID
permission, respectively.
Additionally, we decided to add the NumberStored
and AccountStored
events as well as an error preventing users from setting the
same value twice. All these changes only affect the contract bytecode and not the storage.
Here, it is important to remember how Solidity stores constant`s (and `immutable`s). In contrast to normal variables, they are directly
written into the bytecode on contract creation so that we don’t need to worry that the second `bytes32
constant that we added
shifts down the storage so that the value in uint256 public number
gets lost. It is also important to note that, the initializeFromBuild2
could be left empty. Here, we just emit the events with the currently stored values.
import {PluginUUPSUpgradeable, IDAO} '@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol';
/// @title SimpleStorage build 3
contract SimpleStorageBuild3 is PluginUUPSUpgradeable {
bytes32 public constant STORE_NUMBER_PERMISSION_ID = keccak256('STORE_NUMBER_PERMISSION'); // changed in build 3
bytes32 public constant STORE_ACCOUNT_PERMISSION_ID = keccak256('STORE_ACCOUNT_PERMISSION'); // added in build 3
uint256 public number; // added in build 1
address public account; // added in build 2
// added in build 3
event NumberStored(uint256 number);
event AccountStored(address number);
error AlreadyStored();
/// @notice Initializes the plugin when build 3 is installed.
function initializeBuild3(
IDAO _dao,
uint256 _number,
address _account
) external reinitializer(3) {
__PluginUUPSUpgradeable_init(_dao);
number = _number;
account = _account;
emit NumberStored({number: _number});
emit AccountStored({account: _account});
}
/// @notice Initializes the plugin when the update from build 2 to build 3 is applied.
/// @dev The initialization of `SimpleStorageBuild2` has already happened.
function initializeFromBuild2() external reinitializer(3) {
emit NumberStored({number: number});
emit AccountStored({account: account});
}
/// @notice Initializes the plugin when the update from build 1 to build 3 is applied.
/// @dev The initialization of `SimpleStorageBuild1` has already happened.
function initializeFromBuild1(address _account) external reinitializer(3) {
account = _account;
emit NumberStored({number: number});
emit AccountStored({account: _account});
}
function storeNumber(uint256 _number) external auth(STORE_NUMBER_PERMISSION_ID) {
if (_number == number) revert AlreadyStored();
number = _number;
emit NumberStored({number: _number});
}
function storeAccount(address _account) external auth(STORE_ACCOUNT_PERMISSION_ID) {
if (_account == account) revert AlreadyStored();
account = _account;
emit AccountStored({account: _account});
}
}
Despite no storage-related changes happening in build 3, we must apply the reinitializer(3) modifier to all initialize functions so that
none of them can be called twice or in the wrong order.
|
How to upgrade your plugin
Now that we understand how to create new builds, let’s walk through the process of upgrading your plugin to use them, while maintaining your plugin’s functionality and data integrity.
Create the new build implementation contract
In the previous section, we created a new build implementation contract, we will use this as the new implementation for our plugin.
import {PluginUUPSUpgradeable, IDAO} '@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol';
/// @title SimpleStorage build 2
contract SimpleStorageBuild2 is PluginUUPSUpgradeable {
bytes32 public constant STORE_PERMISSION_ID = keccak256('STORE_PERMISSION');
uint256 public number; // added in build 1
address public account; // added in build 2
/// @notice Initializes the plugin when build 2 is installed.
function initializeBuild2(
IDAO _dao,
uint256 _number,
address _account
) external reinitializer(2) {
__PluginUUPSUpgradeable_init(_dao);
number = _number;
account = _account;
}
/// @notice Initializes the plugin when the update from build 1 to build 2 is applied.
/// @dev The initialization of `SimpleStorageBuild1` has already happened.
function initializeFromBuild1(address _account) external reinitializer(2) {
account = _account;
}
function storeNumber(uint256 _number) external auth(STORE_PERMISSION_ID) {
number = _number;
}
function storeAccount(address _account) external auth(STORE_PERMISSION_ID) {
account = _account;
}
}
Write a new Plugin Setup contract
In order to do update a plugin, we need a prepareUpdate()
function in our Plugin Setup contract which points the functionality to a
new build, as we described before.
The prepareUpdate()
function must transition the plugin from the old build state into the new one so that it ends up having the
same permissions (and helpers) as if it had been freshly installed.
In contrast to the original build 1, build 2 requires two input arguments: uint256 _number
and address _account
that we decode from the bytes-encoded input _data
.
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.21;
import {PermissionLib} from '@aragon/osx/core/permission/PermissionLib.sol';
import {PluginSetup, IPluginSetup} from '@aragon/osx/framework/plugin/setup/PluginSetup.sol';
import {SimpleStorageBuild2} from './SimpleStorageBuild2.sol';
/// @title SimpleStorageSetup build 2
contract SimpleStorageBuild2Setup is PluginSetup {
address private immutable simpleStorageImplementation;
constructor() {
simpleStorageImplementation = address(new SimpleStorageBuild2());
}
/// @inheritdoc IPluginSetup
function prepareInstallation(
address _dao,
bytes memory _data
) external returns (address plugin, PreparedSetupData memory preparedSetupData) {
(uint256 _number, address _account) = abi.decode(_data, (uint256, address));
plugin = createERC1967Proxy(
simpleStorageImplementation,
abi.encodeWithSelector(SimpleStorageBuild2.initializeBuild2.selector, _dao, _number, _account)
);
PermissionLib.MultiTargetPermission[]
memory permissions = new PermissionLib.MultiTargetPermission[](1);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Grant,
where: plugin,
who: _dao,
condition: PermissionLib.NO_CONDITION,
permissionId: SimpleStorageBuild2(this.implementation()).STORE_PERMISSION_ID()
});
preparedSetupData.permissions = permissions;
}
/// @inheritdoc IPluginSetup
function prepareUpdate(
address _dao,
uint16 _currentBuild,
SetupPayload calldata _payload
)
external
pure
override
returns (bytes memory initData, PreparedSetupData memory preparedSetupData)
{
(_dao, preparedSetupData);
if (_currentBuild == 0) {
address _account = abi.decode(_payload.data, (address));
initData = abi.encodeWithSelector(
SimpleStorageBuild2.initializeFromBuild1.selector,
_account
);
}
}
/// @inheritdoc IPluginSetup
function prepareUninstallation(
address _dao,
SetupPayload calldata _payload
) external view returns (PermissionLib.MultiTargetPermission[] memory permissions) {
permissions = new PermissionLib.MultiTargetPermission[](1);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Revoke,
where: _payload.plugin,
who: _dao,
condition: PermissionLib.NO_CONDITION,
permissionId: SimpleStorageBuild2(this.implementation()).STORE_PERMISSION_ID()
});
}
/// @inheritdoc IPluginSetup
function implementation() external view returns (address) {
return simpleStorageImplementation;
}
}
The key thing to review in this new Plugin Setup contract is its prepareUpdate()
function. The function only contains a condition checking from which build number the update is transitioning to build 2
. Here, it is the build number 1
as this is the only update path we support. Inside, we decode the address _account
input argument provided with bytes _data
and pass it to the initializeFromBuild1
function taking care of initializing the storage that was added in this build.
Future builds
For each build we add, we will need to add a prepareUpdate()
function with any parameters needed to update to that implementation.
In this third build, for example, we are modifying the bytecode of the plugin.
Third plugin build example, modifying the plugin’s bytecode.
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.21;
import {IDAO, PluginUUPSUpgradeable} from '@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol';
/// @title SimpleStorage build 3
contract SimpleStorageBuild3 is PluginUUPSUpgradeable {
bytes32 public constant STORE_NUMBER_PERMISSION_ID = keccak256('STORE_NUMBER_PERMISSION'); // changed in build 3
bytes32 public constant STORE_ACCOUNT_PERMISSION_ID = keccak256('STORE_ACCOUNT_PERMISSION'); // added in build 3
uint256 public number; // added in build 1
address public account; // added in build 2
// added in build 3
event NumberStored(uint256 number);
event AccountStored(address account);
error AlreadyStored();
/// @notice Initializes the plugin when build 3 is installed.
function initializeBuild3(
IDAO _dao,
uint256 _number,
address _account
) external reinitializer(3) {
__PluginUUPSUpgradeable_init(_dao);
number = _number;
account = _account;
emit NumberStored({number: _number});
emit AccountStored({account: _account});
}
/// @notice Initializes the plugin when the update from build 2 to build 3 is applied.
/// @dev The initialization of `SimpleStorageBuild2` has already happened.
function initializeFromBuild2() external reinitializer(3) {
emit NumberStored({number: number});
emit AccountStored({account: account});
}
/// @notice Initializes the plugin when the update from build 1 to build 3 is applied.
/// @dev The initialization of `SimpleStorageBuild1` has already happened.
function initializeFromBuild1(address _account) external reinitializer(3) {
account = _account;
emit NumberStored({number: number});
emit AccountStored({account: _account});
}
function storeNumber(uint256 _number) external auth(STORE_NUMBER_PERMISSION_ID) {
if (_number == number) revert AlreadyStored();
number = _number;
emit NumberStored({number: _number});
}
function storeAccount(address _account) external auth(STORE_ACCOUNT_PERMISSION_ID) {
if (_account == account) revert AlreadyStored();
account = _account;
emit AccountStored({account: _account});
}
}
With each new build implementation, we will need to update the Plugin Setup contract to be able to update to that new version.
We do this through updating the prepareUpdate()
function to support any new features that need to be set up.
Third plugin setup example, modifying prepareUpdate
function.
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.21;
import {PermissionLib} from '@aragon/osx/core/permission/PermissionLib.sol';
import {PluginSetup, IPluginSetup} from '@aragon/osx/framework/plugin/setup/PluginSetup.sol';
import {SimpleStorageBuild2} from '../build2/SimpleStorageBuild2.sol';
import {SimpleStorageBuild3} from './SimpleStorageBuild3.sol';
/// @title SimpleStorageSetup build 3
contract SimpleStorageBuild3Setup is PluginSetup {
address private immutable simpleStorageImplementation;
constructor() {
simpleStorageImplementation = address(new SimpleStorageBuild3());
}
/// @inheritdoc IPluginSetup
function prepareInstallation(
address _dao,
bytes memory _data
) external returns (address plugin, PreparedSetupData memory preparedSetupData) {
(uint256 _number, address _account) = abi.decode(_data, (uint256, address));
plugin = createERC1967Proxy(
simpleStorageImplementation,
abi.encodeWithSelector(SimpleStorageBuild3.initializeBuild3.selector, _dao, _number, _account)
);
PermissionLib.MultiTargetPermission[]
memory permissions = new PermissionLib.MultiTargetPermission[](2);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Grant,
where: plugin,
who: _dao,
condition: PermissionLib.NO_CONDITION,
permissionId: SimpleStorageBuild3(this.implementation()).STORE_NUMBER_PERMISSION_ID()
});
permissions[1] = permissions[0];
permissions[1].permissionId = SimpleStorageBuild3(this.implementation())
.STORE_ACCOUNT_PERMISSION_ID();
preparedSetupData.permissions = permissions;
}
/// @inheritdoc IPluginSetup
function prepareUpdate(
address _dao,
uint16 _currentBuild,
SetupPayload calldata _payload
)
external
view
override
returns (bytes memory initData, PreparedSetupData memory preparedSetupData)
{
if (_currentBuild == 0) {
address _account = abi.decode(_payload.data, (address));
initData = abi.encodeWithSelector(
SimpleStorageBuild3.initializeFromBuild1.selector,
_account
);
} else if (_currentBuild == 1) {
initData = abi.encodeWithSelector(SimpleStorageBuild3.initializeFromBuild2.selector);
}
PermissionLib.MultiTargetPermission[]
memory permissions = new PermissionLib.MultiTargetPermission[](3);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Revoke,
where: _dao,
who: _payload.plugin,
condition: PermissionLib.NO_CONDITION,
permissionId: keccak256('STORE_PERMISSION')
});
permissions[1] = permissions[0];
permissions[1].operation = PermissionLib.Operation.Grant;
permissions[1].permissionId = SimpleStorageBuild3(this.implementation())
.STORE_NUMBER_PERMISSION_ID();
permissions[2] = permissions[1];
permissions[2].permissionId = SimpleStorageBuild3(this.implementation())
.STORE_ACCOUNT_PERMISSION_ID();
preparedSetupData.permissions = permissions;
}
/// @inheritdoc IPluginSetup
function prepareUninstallation(
address _dao,
SetupPayload calldata _payload
) external view returns (PermissionLib.MultiTargetPermission[] memory permissions) {
permissions = new PermissionLib.MultiTargetPermission[](2);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Revoke,
where: _payload.plugin,
who: _dao,
condition: PermissionLib.NO_CONDITION,
permissionId: SimpleStorageBuild3(this.implementation()).STORE_NUMBER_PERMISSION_ID()
});
permissions[1] = permissions[1];
permissions[1].permissionId = SimpleStorageBuild3(this.implementation())
.STORE_ACCOUNT_PERMISSION_ID();
}
/// @inheritdoc IPluginSetup
function implementation() external view returns (address) {
return simpleStorageImplementation;
}
}
In this case, the prepareUpdate()
function only contains a condition checking from which build number the update is transitioning
to build 2. Here, we can update from build 0 or build 1 and different operations must happen for each case to transition to
SimpleAdminBuild3
.
In the first case, initializeFromBuild1
is called taking care of initializing address _account
that was added in build 1 and
emitting the events added in build 2.
In the second case, initializeFromBuild2
is called taking care of initializing the build. Here, only the two events will be emitted.
Lastly, the prepareUpdate()
function takes care of modifying the permissions by revoking the STORE_PERMISSION_ID
and granting
the more specific STORE_NUMBER_PERMISSION_ID
and STORE_ACCOUNT_PERMISSION_ID
permissions, that are also granted if build 2 is
freshly installed. This must happen for both update paths so this code is outside the if
statements.