Skip to content

Commit fa2dc15

Browse files
authored
add proxy contracts to simplify upgradability (#51)
* add proxy contracts to simplify upgradability * implement proxy tests, harden code, add/update READMEs * emphasize exclusive storage slot pattern usage in proxy README
1 parent d9a9574 commit fa2dc15

File tree

8 files changed

+366
-3
lines changed

8 files changed

+366
-3
lines changed

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ The `main` branch is considered the nightly version of the SDK. Stick to tagged
2626
forge install wormhole-foundation/[email protected]
2727
```
2828

29-
**Solc Version**
29+
**EVM Version**
3030

31-
Currently the SDK uses solc version 0.8.19 to avoid issues with PUSH0 which was introduced in 0.8.20 but which is not supported on many EVM chains.
31+
One hazard of developing EVM contracts in a cross-chain environment is that different chains have varying levels EVM-equivalence. This means you have to ensure that all chains that you are planning to deploy to support all EIPs/opcodes that you rely on.
32+
33+
For example, if you are using a solc version newer than `0.8.19` and are planning to deploy to a chain that does not support [PUSH0 opcode](https://eips.ethereum.org/EIPS/eip-3855) (introduced as part of the Shanghai hardfork), you should set `evm_version = "paris"` in your `foundry.toml`, since the default EVM version of solc was advanced from Paris to Shanghai as part of solc's `0.8.20` release.
34+
35+
## Philosophy/Creeds
36+
37+
In This House We Believe:
38+
* clarity breeds security
39+
* Do NOT trust in the Lord (i.e. the community, auditors, fellow devs, FOSS, ...) with any of your heart (i.e. with your or your users' security), but lean _hard_ on your own understanding.
40+
* _Nothing_ is considered safe unless you have _personally_ verified it as such.
41+
* git gud
42+
* shut up and suffer
3243

3344
## WormholeRelayer
3445

foundry.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[profile.default]
2-
solc_version = "0.8.19"
2+
solc_version = "0.8.24"
3+
evm_version = "paris" # prevent use of PUSH0 opcode until it is widely supported
34
src = "src"
45
out = "out"
56
libs = ["lib"]

src/proxy/Eip1967Admin.sol

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.24;
4+
5+
// optional default implementation of eip1967 admin storage
6+
//
7+
// examples of natural extensions/overrides are:
8+
// - additional pendingAdmin for 2-step ownership transfers
9+
// - storing additional roles (after the admin slot)
10+
11+
struct AdminState {
12+
address admin;
13+
}
14+
15+
// we use the designated eip1967 admin storage slot: keccak256("eip1967.proxy.admin") - 1
16+
bytes32 constant ADMIN_SLOT =
17+
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
18+
19+
function adminState() pure returns (AdminState storage state) {
20+
assembly ("memory-safe") { state.slot := ADMIN_SLOT }
21+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.24;
4+
5+
struct ImplementationState {
6+
address implementation;
7+
bool initialized;
8+
}
9+
10+
// we use the designated eip1967 storage slot: keccak256("eip1967.proxy.implementation") - 1
11+
bytes32 constant IMPLEMENTATION_SLOT =
12+
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
13+
14+
function implementationState() pure returns (ImplementationState storage state) {
15+
assembly ("memory-safe") { state.slot := IMPLEMENTATION_SLOT }
16+
}

src/proxy/Proxy.sol

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.24;
4+
5+
import { implementationState } from "./Eip1967Implementation.sol";
6+
7+
error ProxyConstructionFailed(bytes revertData);
8+
9+
//slimmed down, more opinionated implementation of the EIP1967 reference implementation
10+
// see: https://eips.ethereum.org/EIPS/eip-1967
11+
contract Proxy {
12+
constructor(address logic, bytes memory data) payable {
13+
implementationState().implementation = logic;
14+
15+
//We can't externally call ourselves and use msg.sender to prevent unauhorized execution of
16+
// the construction code, because the proxy's code only gets written to state when the
17+
// deployment transaction completes (and returns the deployed bytecode via CODECOPY).
18+
//So we only have delegatecall at our disposal and instead use an initialized flag (stored in
19+
// the same storage slot as the implementation address) to prevent invalid re-initialization.
20+
(bool success, bytes memory revertData) =
21+
logic.delegatecall(abi.encodeWithSignature("checkedUpgrade(bytes)", (data)));
22+
23+
if (!success)
24+
revert ProxyConstructionFailed(revertData);
25+
}
26+
27+
fallback() external payable {
28+
//can't just do a naked sload of the implementation slot here because it also contains
29+
// the initialized flag!
30+
address implementation = implementationState().implementation;
31+
assembly {
32+
calldatacopy(0, 0, calldatasize())
33+
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
34+
returndatacopy(0, 0, returndatasize())
35+
switch result
36+
case 0 {
37+
revert(0, returndatasize())
38+
}
39+
default {
40+
return(0, returndatasize())
41+
}
42+
}
43+
}
44+
}

src/proxy/ProxyBase.sol

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.24;
4+
5+
import { implementationState } from "./Eip1967Implementation.sol";
6+
7+
error InvalidSender();
8+
error IdempotentUpgrade();
9+
error InvalidMsgValue();
10+
error InvalidData();
11+
error UpgradeFailed(bytes revertData);
12+
13+
event Upgraded(address indexed implementation);
14+
15+
//works with both standard EIP1967 proxies and our own, slimmed down Proxy contract
16+
abstract contract ProxyBase {
17+
//address private immutable _logicContract = address(this);
18+
19+
//payable for proxyConstructor use case
20+
//selector: f4189c473
21+
function checkedUpgrade(bytes calldata data) payable external {
22+
if (msg.sender != address(this)) {
23+
if (implementationState().initialized)
24+
revert InvalidSender();
25+
26+
_proxyConstructor(data);
27+
}
28+
else
29+
_contractUpgrade(data);
30+
31+
//If we upgrade from an old OpenZeppelin proxy, then initialized will not have been set to true
32+
// even though the constructor has been called, so we simply manually set it here in all cases.
33+
//This is slightly gas inefficient but better to be safe than sorry for rare use cases like
34+
// contract upgrades.
35+
implementationState().initialized = true;
36+
}
37+
38+
//msg.value should be enforced/checked before calling _upgradeTo
39+
function _upgradeTo(address newImplementation, bytes memory data) internal {
40+
if (newImplementation == implementationState().implementation)
41+
revert IdempotentUpgrade();
42+
43+
implementationState().implementation = newImplementation;
44+
45+
(bool success, bytes memory revertData) =
46+
address(this).call(abi.encodeCall(this.checkedUpgrade, (data)));
47+
48+
if (!success)
49+
revert UpgradeFailed(revertData);
50+
51+
emit Upgraded(newImplementation);
52+
}
53+
54+
function _getImplementation() internal view returns (address) {
55+
return implementationState().implementation;
56+
}
57+
58+
function _proxyConstructor(bytes calldata data) internal virtual {
59+
if (msg.value > 0)
60+
revert InvalidMsgValue();
61+
62+
_noDataAllowed(data);
63+
64+
//!!don't forget to check/enforce msg.value when overriding!!
65+
}
66+
67+
function _contractUpgrade(bytes calldata data) internal virtual {
68+
_noDataAllowed(data);
69+
70+
//override and implement in the new logic contract (if required)
71+
}
72+
73+
function _noDataAllowed(bytes calldata data) internal pure {
74+
if (data.length > 0)
75+
revert InvalidData();
76+
}
77+
}

src/proxy/README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Proxy
2+
3+
An opinionated, minimalist, efficient implementation for contract upgradability.
4+
5+
Based on [ERC-1967](https://eips.ethereum.org/EIPS/eip-1967), but skips all features that aren't strictly necessary for the most basic upgradeability use case (such as beacon, rollback, and admin slots/functionality) so as to provide the slimmest (in terms of bytecode), most straight-forward (in terms of readability and usability), no-nonsense solution.
6+
7+
I'm using the term "logic contract" here because it is a lot clearer/unambiguous than the more generic "implementation", while the actual code uses implementation to stick with the established terms within the context.
8+
9+
## Usage in a Nutshell
10+
11+
See test/Proxy.t.sol for a straight-forward example.
12+
13+
### Implementation
14+
15+
1. Implement a (logic) contract that inherits from `ProxyBase`.
16+
2. Implement a constructor to initalize all `immutable` variables of your contract.
17+
3. Only use the slot pattern for a storage variables. Normal storage variables are of the devil.
18+
4. Override `_proxyConstructor` as necessary to initialize all storage variables of your contract as necessary (and don't forget to manually check `msg.value` since the constructor of `Proxy` is `payable`!).
19+
5. Implement a function with a name (and access restrictions!) of your choosing that calls `_upgradeTo` internally. Make `payable` and emit an event as necessary/desired.
20+
21+
### Deployment
22+
23+
1. Deploy the your logic contract via its constructor.
24+
2. Deploy `Proxy` with the address of your logic contract and `bytes` as required by `_proxyConstructor`.
25+
Alternatively to step 2. you can also deploy a standard `ERC1967Proxy` (or through an `ERC1967Proxy` factory), in which case you have to manually encode the call to `checkedUpgrade`.
26+
27+
### Upgrade
28+
29+
1. Override `_contractUpgrade` in your new version of the logic contract and implement all migration logic there.
30+
2. Invoke the upgrade through your own upgrade function (step 4 in the Implementation section).
31+
32+
## Rationale
33+
34+
There are enough upgradability standards, patterns, and libraries out there to make anyone's head spin:
35+
* [ERC-1967](https://eips.ethereum.org/EIPS/eip-1967)
36+
* [UUPS ERC-1822](https://eips.ethereum.org/EIPS/eip-1822)
37+
* [Diamond pattern ERC-2535](https://eips.ethereum.org/EIPS/eip-2535)
38+
39+
And then there's a bunch of implementations in the various EVM SDK repos:
40+
* [OpenZeppelin](https://github.com/OpenZeppelin/openzeppelin-contracts)
41+
* [solmate](https://github.com/transmissions11/solmate)
42+
* [solady](https://github.com/Vectorized/solady)
43+
44+
And especially OZ, which is very commonly used, has a bunch of patterns on top of that, requiring a separate [upgradeable repo](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable) with its `init` and `init_unchained` functions, it's `initializer` and `reinitializer` modifiers, etc. etc.
45+
46+
While these cover all conceivable use cases (and then some), their inherent complexity and sheer amount of code can easily make a reader's eyes glaze over.
47+
48+
The goal of this proxy solution is to provide a concise, light-weight, easily understandable solution for the most common upgradeability use case.
49+
50+
And thus [a new implementation](https://xkcd.com/927/) is born.
51+
52+
## Design
53+
54+
### ProxyBase
55+
56+
Besides stripping all non-essential code, `ProxyBase` is opinionated in two ways:
57+
1. It separates the act of construction from the act of upgrading via two `internal` `virtual` methods called `_proxyConstructor` and `_contractUpgrade` respectively.
58+
2. It provides an external function called `checkedUpgrade` to execute these two methods, while automatically handling access control by automatically keeping track of an initialized flag (for construction) and by restricting external calls to `checkedUpgrade` to the proxy contract alone.
59+
60+
Additionally, `checkedUpgrade` has a high function selector (starts with `0xf4`) which saves gas on every other external function call on the contract, since any function with a selector lower than the one that is being invoked results in a gas overhead of 22 gas andless than 5 % of functions will, on average, have a selector higher than `checkedUpgrade`'s.
61+
62+
### Proxy
63+
64+
`Proxy` also strips all non-essential code and is the intended pairing of `ProxyBase`, though the latter is compatible with any `ERC-1967` proxy implementation (and can therefore also be used with any proxy factories that might have already been deployed on-chain). The advantage of using `Proxy` over other proxy implementations is that one does not have to encode the full initialization function call signature in the calldata, but only has to pack the arguments for `_proxyConstructor` and pass them along with the address of the logic contract.
65+
66+
## Limitations
67+
68+
### No Rollback Functionality
69+
70+
Since the upgrade mechanism isn't baked into the proxy itself but relies on the logic contract, it is possible to brick a contract with an upgrade to a faulty implementation. Simply supplying an incorrect address will not work, since the upgrade mechanism relies on the `checkedUpgrade` function to exist on the new contract, but if the upgrade mechanism of the new implementation is broken, then there's no way to roll back an upgrade.
71+
72+
Mitigation: See "git gud" creed. When it comes to upgradability, the #1 directive is: Avoid one-way door errors.
73+
74+
### No Version Checking
75+
76+
If there are several version of a given contract and the upgrades are meant to be applied sequentially, depending on the migration code of the individual version, it's possible to break/brick a contract by accidentally skip one of the upgrades.
77+
78+
Mitigation: Upgrade all your contracts whenever you release a new version. If this isn't an exceedingly rare event in the first place, perhaps you should take up a different occupation like farming, or crash test dummy.
79+
80+
### Self-destruct
81+
82+
If you are using the SELFDESTRUCT opcode (formerly known as SUICIDE before Ethereum's [most pointless EIP](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-6.md) got adopted) in your contract and are deploying to a chain that hasn't implemented [EIP-6780](https://eips.ethereum.org/EIPS/eip-6780) yet, you should really know what you are doing lest you are prepared to go the way of Parity Multisig.
83+
84+
Mitigation: Always treat guns as if they are loaded, point them in a safe direction, and keep the finger off the trigger.

test/Proxy.t.sol

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
// forge test --match-contract QueryTest
4+
5+
pragma solidity ^0.8.24;
6+
7+
import "forge-std/Test.sol";
8+
9+
import { adminState } from "wormhole-sdk/proxy/Eip1967Admin.sol";
10+
import { ProxyBase, UpgradeFailed, InvalidData } from "wormhole-sdk/proxy/ProxyBase.sol";
11+
import { Proxy, ProxyConstructionFailed } from "wormhole-sdk/proxy/Proxy.sol";
12+
13+
error NotAuthorized();
14+
error NoValueAllowed();
15+
16+
contract LogicContractV1 is ProxyBase {
17+
uint256 public immutable immutableNum;
18+
string public message;
19+
20+
constructor(uint256 num) {
21+
immutableNum = num;
22+
}
23+
24+
function _proxyConstructor(bytes calldata data) internal override {
25+
if (msg.value != 0)
26+
revert NoValueAllowed();
27+
28+
adminState().admin = msg.sender;
29+
message = abi.decode(data, (string));
30+
}
31+
32+
function getImplementation() external view returns (address) {
33+
return _getImplementation();
34+
}
35+
36+
function customUpgradeFun(address newImplementation, bytes calldata data) external {
37+
if (msg.sender != adminState().admin)
38+
revert NotAuthorized();
39+
40+
_upgradeTo(newImplementation, data);
41+
}
42+
}
43+
44+
contract LogicContractV2 is LogicContractV1 {
45+
constructor(uint256 num) LogicContractV1(num) {}
46+
47+
function _contractUpgrade(bytes calldata data) internal override {
48+
message = abi.decode(data, (string));
49+
}
50+
}
51+
52+
contract TestProxy is Test {
53+
function testProxyUpgrade() public {
54+
address admin = makeAddr("admin");
55+
address rando = makeAddr("rando");
56+
57+
address logic1 = address(new LogicContractV1(1));
58+
address logic2 = address(new LogicContractV2(2));
59+
60+
startHoax(admin);
61+
//no value allowed
62+
vm.expectRevert(abi.encodeWithSelector(
63+
ProxyConstructionFailed.selector,
64+
abi.encodePacked(bytes4(NoValueAllowed.selector))
65+
));
66+
new Proxy{value: 1 ether}(logic1, abi.encode("v1"));
67+
68+
//deploy
69+
LogicContractV1 contrct = LogicContractV1(address(new Proxy(logic1, abi.encode("v1"))));
70+
71+
assertEq(contrct.getImplementation(), logic1);
72+
assertEq(contrct.immutableNum(), 1);
73+
assertEq(contrct.message(), "v1");
74+
75+
startHoax(rando);
76+
//unauthorized upgrade
77+
vm.expectRevert(NotAuthorized.selector);
78+
contrct.customUpgradeFun(logic2, abi.encode("v2"));
79+
80+
startHoax(admin);
81+
//upgrade
82+
contrct.customUpgradeFun(logic2, abi.encode("v2"));
83+
84+
assertEq(contrct.getImplementation(), logic2);
85+
assertEq(contrct.immutableNum(), 2);
86+
assertEq(contrct.message(), "v2");
87+
88+
startHoax(rando);
89+
//unauthorized downgrade
90+
vm.expectRevert(NotAuthorized.selector);
91+
contrct.customUpgradeFun(logic1, new bytes(0));
92+
93+
startHoax(admin);
94+
//v1 uses default _contractUpgrade implementation which reverts on any data
95+
vm.expectRevert(abi.encodeWithSelector(
96+
UpgradeFailed.selector,
97+
abi.encodePacked(bytes4(InvalidData.selector))
98+
));
99+
contrct.customUpgradeFun(logic1, abi.encode("downgrade"));
100+
101+
//questionable, but possible downgrade:
102+
contrct.customUpgradeFun(logic1, new bytes(0));
103+
104+
//highlight hazards of questionable downgrade:
105+
assertEq(contrct.getImplementation(), logic1);
106+
assertEq(contrct.immutableNum(), 1);
107+
assertEq(contrct.message(), "v2");
108+
}
109+
}

0 commit comments

Comments
 (0)