@cardstack/upgrade-manager
v1.0.1
Published
Cardstack Smart Contract Upgrade Manager
Downloads
8
Readme
@cardstack/upgrade-manager
The upgrade manager allows managing a set of smart contracts deployed to a chain, handling proxy upgrade and batched configuration application, with tooling to support management with a M-of-N gnosis safe.
Currently supported are OpenZepplin transparent ugpradeable proxy contracts and implementations, along with the concept of "abstract contracts", which can be used as non-upgradeable implementations for when different proxy mechanisms are in use, a common example being Gnosis Safe delegate implementations, or any other custom DELEGATECALL mechanism.
Architecture
The upgrade manager consists of:
UpgradeManager.sol
A solidity contract that you deploy once per chain for your hardhat project.
The UpgradeManager is owned by either an EOA or a Gnosis safe(recommended). It becomes the owner of all your other contracts along with the owner of their ProxyAdmin contracts.
This assumes that your upgradeable contracts support this interface:
interface Ownable {
function owner() public view returns (address);
function transferOwnership(address newOwner) public;
}
Once the contracts are owned by the upgrade manager, the upgrade manager is responsible for both upgrading their implementation, and calling arbitrary config methods on them.
To propose an upgrade or a config method call, or both at the same time, a set
of upgrade proposers can call the proposeUpgrade
, proposeCall
, and
proposeUpgradeAndCall
methods respectively. The upgrade proposers can be
accounts that have a lower level of trust than the upgrade manager owner. For
example, on a development team, all the developers could be proposers,
allowing them to stage changes without those changes taking effect yet.
Once the upgrades and calls are all proposed, then the owner of the upgrade manager must approve all of these changes. This could be a single EOA but for production usage, a gnosis safe owner is recommended with a suitable M-of-N owner and threshold configuration.
The changes are applied atomically, so if you have multiple contracts which depend on each other and need to be configured with each others' addresses, there is no point-in-time where your projects contracts are partially configured or partially upgraded. Either all are upgraded and configured, or none are, from the perspective of any external transaction (note - if you use call or upgradeAndCall, then this may not be true for those internal transactions so you should be careful with what you do in those functions).
The only limit to the amount of changes that can be applied atomically is the block gas limit, and if the gas usage is too large then changes can be easily withdrawn to reduce gas usage.
Hardhat plugin
The hardhat plugin is responsible for handling upgrades and configuration of the contracts in your hardhat project.
You configure in your hardhat config file a list of the contracts you want to
deploy, and you also add a config directory with a js or ts file for each
contract you want to configure. When you run the provided hardhat deploy
task, the plugin will check the current on-chain state and bytecode,
compare it to your local code and configuration, and generate the set of
changes needed for the blockchain state to be what is required. The scripts
will then make the appropriate transactions to the UpgradeManager contract
to stage these upgrades and configration.
The current state of your configured contracts can be shown with the
hardhat deploy:status
command:
$ hardhat deploy:status
┌───────────────────────────────┬─────────────────────────┬────────────────────────────────────────────┬────────────────────────────────────────────┬────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────┬────────────────────────┐
│ Contract ID │ Contract Name │ Proxy Address │ Current Implementation Address │ Proposed Implementation Address │ Proposed Function Call │ Local Bytecode Changed │
├───────────────────────────────┼─────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ MockUpgradeableContract │ MockUpgradeableContract │ 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 │ 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 │ │ setup( │ │
│ │ │ │ │ │ string _fooString: "foo string value", │ │
│ │ │ │ │ │ address _barAddress: "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6" │ │
│ │ │ │ │ │ ) │ │
├───────────────────────────────┼─────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ MockUpgradeableSecondInstance │ MockUpgradeableContract │ 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 │ 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 │ │ setup( │ │
│ │ │ │ │ │ string _fooString: "foo string value second hardhat", │ │
│ │ │ │ │ │ address _barAddress: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" │ │
│ │ │ │ │ │ ) │ │
├───────────────────────────────┼─────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ AbstractContract │ AbstractContract │ │ N/A (proposed) │ 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 │ │ YES │
├───────────────────────────────┼─────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┼────────────────────────┤
│ DeterministicContract │ AbstractContract │ │ N/A (proposed) │ 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 │ │ YES │
└───────────────────────────────┴─────────────────────────┴────────────────────────────────────────────┴────────────────────────────────────────────┴────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────┴────────────────────────┘
The diff between local / proposed code and on-chain code can also be displayed with the hardhat deploy:diff:local
and hardhat deploy:diff:proposed
commands.
If everything looks good, the upgrade manager owner can use the hardhat
deploy:upgrade
command to execute all proposed changes atomically. If the
upgrade manager is owned by a gnosis safe, this is automatically detected and
instead of submitting the transaction, json with the current and previous
users' signatures is output, allowing the next owner to add their signature
until enough are collected to meet the safe's threshold
Installation
npm install @cardstack/upgrade-manager
Import the plugin in your hardhat.config.js
:
require("@cardstack/upgrade-manager");
Or if you are using TypeScript, in your hardhat.config.ts
:
import "@cardstack/upgrade-manager";
Hardhat Configuration
This plugin extends the HardhatUserConfig
object with the upgradeManager field.
This is an example of how to set it:
module.exports = {
upgradeManager: {
contracts: [
"FooContract",
{
id: "FooContractWithDifferentId",
contract: "FooContract",
},
{
id: "AbstractContract",
abstract: true,
},
{
id: "DeterministicContract",
contract: "AbstractContract",
abstract: true,
deterministic: true,
},
],
},
};
Each item in the contracts array can either be a string, to simply deploy an upgadeable proxy with the same id as the contract's name, or an object representing configuration of the contract. The options are as follows:
id
: The arbitrary id you choose to reference this contract. Must be unique.contract
: The name of the contract from your projects artifacts. You can deploy multiple instances of the same contract with different ids if requiredabstract
: Deploy an abstract contract instead of an upgradeable proxy. Abstract contracts do not have config and are intended to be "implementation only", so that you can set an implementation for e.g. a safe delegate implementation or another type of DELEGATECALL proxy mechanismdeterministic
: "Deploy to a stable address based on the contract bytecode using deterministic-deployment-proxy. Only supported for abstract contracts"
Deploy command
Usage: hardhat [GLOBAL OPTIONS] deploy [--auto-confirm <BOOLEAN>] [--derivation-path <STRING>] [--dry-run <BOOLEAN>] [--fork <STRING>] [--immediate-config-apply <BOOLEAN>] [--impersonate-address <STRING>]
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false)
--derivation-path Derivation path to use when using mnemonic or trezor
--dry-run Preview what would happen, without actually writing to the blockchain (default: false)
--fork The network to fork
--immediate-config-apply If there are a large series of calls e.g. during initial setup, apply config immediately by calling methods directly on contracts instead of proposing config changes (default: false)
--impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking)
deploy: Deploys new contracts and propose implementation and config changes for existing deployed contracts
Example
hardhat deploy --network goerli
Forking Example
This will run the deploy against an in-memory fork so you can preview changes. This assumes you have an rpc url configured correctly for this network in your hardhat config
hardhat deploy --fork goerli --impersonate-address $UPGRADE_MANAGER_OWNER_ADDRESS
Forking with a persistant node
You may want to preview multiple steps against a fork. To acheive this, first start a forked node:
hardhat node --fork $RPC_URL
Then run multiple commands against the forked node:
hardhat deploy --network localhost --fork goerli --impersonate-address $UPGRADE_MANAGER_OWNER_ADDRESS
hardhat deploy:upgrade --network localhost --fork goerli --impersonate-address $UPGRADE_MANAGER_OWNER_ADDRESS
deploy:upgrade
command
Usage: hardhat [GLOBAL OPTIONS] deploy:upgrade [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--prior-signatures ] newVersion
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
newVersion The new version number to set on the upgrade manager. Does not have to increase or change
deploy:upgrade: Applies pending contract upgrades and config changes atomically
Contract configuration
For each contract id above that you want to configure, add a file in the config/
subdirectory of your hardhat project, for example:
import { ConfigFunction } from "@cardstack/upgrade-manager/types";
let config: ConfigFunction = async function ({ address }) {
return {
setup: [
{ getter: "fooString", value: "foo string value" },
{
getter: "barAddress",
value: address("MockUpgradeableSecondInstance"),
},
],
};
};
export default config;
or in javascript:
module.exports = async function ({ address, deployConfig }) {
return {
setup: [
{
getter: "fooString",
value: `foo string value second ${deployConfig.network}`,
},
{
getter: "barAddress",
value: address("MockUpgradeableContract"),
},
],
};
};
The keys of the exported objects each represent a config function that should be called on your contract.
The values for each key is an array of the paramaters to your setup function.
The getter
field is a function to call on your contract to check the current
on-chain value. The value
field is what the value should be set to after
configuration is complete.
You can use the deployConfig.network
field passed in to the config function
if different configuration is required based on network. The hre is also a
property of deployConfig, so you can switch based on other hardhat
environment settings too.
This expects roughly the following configuration pattern in your contracts:
contract MockUpgradeableContract {
string public fooString;
address public barAddress;
function setup(string memory _fooString, address _barAddress) external {
fooString = _fooString;
barAddress = _barAddress;
}
}
The reason to use a single setter method instead of a setter for each property is to avoid contract-bloat with many setter functions. Usually this would be inconvenient to manually manage, however with the automated configuration provided by the UpgradeManager this optimisation is no longer inconvenient to use
Adding and removing upgrade proposers
deploy:add-proposer
Usage: hardhat [GLOBAL OPTIONS] deploy:add-proposer [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--prior-signatures ] proposerAddress
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
proposerAddress The proposer address to add
deploy:add-proposer: Adds a proposer
deploy:remove-proposer
Usage: hardhat [GLOBAL OPTIONS] deploy:remove-proposer [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--prior-signatures ] proposerAddress
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
proposerAddress The proposer address to remove
deploy:remove-proposer: Removes a proposer
Gnosis Safe Ownership of upgrade manager
It is recommended that after initial deploy, you transfer ownership of the upgrade manager to a gnosis safe.
deploy:safe-setup
Usage: hardhat [GLOBAL OPTIONS] deploy:safe-setup [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--prior-signatures ] newSafeOwners [newSafeThreshold]
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
newSafeOwners The new owners of the safe, comma seperated addresses newSafeThreshold The new threshold for the safe (default: 1)
deploy:safe-setup: Setup a new Gnosis Safe contract and transfer ths ownership of the upgrade manager to the new safe
deploy:add-safe-owner
Usage: hardhat [GLOBAL OPTIONS] deploy:add-safe-owner [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--new-safe-threshold ] [--prior-signatures ] newSafeOwnerAddress
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --new-safe-threshold The new threshold for the safe, if it changes --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
newSafeOwnerAddress The safe owner address to add
deploy:add-safe-owner: Adds a safe owner
deploy:remove-safe-owner
Usage: hardhat [GLOBAL OPTIONS] deploy:remove-safe-owner [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--new-safe-threshold ] [--prior-signatures ] removeSafeOwnerAddress
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --new-safe-threshold The new threshold for the safe, if it changes --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
removeSafeOwnerAddress The safe owner address to remove
deploy:remove-safe-owner: Removes a safe owner
deploy:set-safe-threshold
Usage: hardhat [GLOBAL OPTIONS] deploy:set-safe-threshold [--auto-confirm ] [--derivation-path ] [--fork ] [--impersonate-address ] [--mnemonic ] [--prior-signatures ] newSafeThreshold
OPTIONS:
--auto-confirm Don't ask for confirmation, useful in scripts / tests (default: false) --derivation-path Derivation path to use when using mnemonic or trezor --fork The network to fork --impersonate-address Address to impersonate deploying from (usually only makes sense whilst forking) --mnemonic Mnemonic to use for deploy actions --prior-signatures Prior safe signatures collected for this operation
POSITIONAL ARGUMENTS:
newSafeThreshold The new threshold for the safe
deploy:set-safe-threshold: Sets the threshold for a safe
Testing
Running yarn test
will run the solidity tests along with the plugin tests
Linting and autoformat
You can check if your code style is correct by running yarn lint
, and fix
it with yarn lint:fix
.