diff --git a/.github/workflows/upgrade-safety-check.yml b/.github/workflows/upgrade-safety-check.yml new file mode 100644 index 0000000..031f02f --- /dev/null +++ b/.github/workflows/upgrade-safety-check.yml @@ -0,0 +1,28 @@ +name: Upgrade Safety Check + +on: + pull_request: + branches: + - dev + +jobs: + upgrade-safety: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Node dependencies + run: yarn --frozen-lockfile --network-concurrency 1 + + - name: Fetch dev branch + run: git fetch origin dev:refs/remotes/origin/dev + + - name: Check SavingCircles upgrade safety + run: ./scripts/check_upgrade_safety.sh origin/dev HEAD src/contracts/SavingCircles.sol:SavingCircles diff --git a/script/Common.sol b/script/Common.sol index 90a9112..78ffd0a 100644 --- a/script/Common.sol +++ b/script/Common.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.28; import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; import {TransparentUpgradeableProxy} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; import {Script} from 'forge-std/Script.sol'; +import {console2} from 'forge-std/console2.sol'; import {DelegatedSavingCircles} from '../src/contracts/DelegatedSavingCircles.sol'; import {SavingCircles} from '../src/contracts/SavingCircles.sol'; @@ -16,35 +17,90 @@ import {SavingCirclesViewer} from '../src/contracts/SavingCirclesViewer.sol'; * @dev This contract is intended for use in Scripts and Integration Tests */ contract Common is Script { + bytes32 internal constant _ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 internal constant _ERC1967_ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + bytes4 internal constant _OWNER_SELECTOR = bytes4(keccak256('owner()')); + bytes4 internal constant _UPGRADE_INTERFACE_VERSION_SELECTOR = bytes4(keccak256('UPGRADE_INTERFACE_VERSION()')); + + error InvalidAdminAddress(); + error InvalidAdminProxyAdmin(address admin); + error ProxyAdminOwnerMismatch(address proxyAdmin, address expectedOwner, address actualOwner); + error ProxyAdminNotDeployed(address proxy); + error ProxyImplementationSlotMismatch(address proxy, address expectedImplementation, address actualImplementation); + function setUp() public virtual {} function _deploySavingCircles() internal returns (SavingCircles) { return new SavingCircles(); } - function _deployProxyAdmin(address _admin) internal returns (ProxyAdmin) { - return new ProxyAdmin(_admin); - } - function _deployTransparentProxy( address _implementation, - address _proxyAdmin, + address _adminOwner, bytes memory _initData ) internal returns (TransparentUpgradeableProxy) { - return new TransparentUpgradeableProxy(_implementation, _proxyAdmin, _initData); + return new TransparentUpgradeableProxy(_implementation, _adminOwner, _initData); } function _deployContracts(address _admin) internal returns (TransparentUpgradeableProxy) { + _assertValidAdmin(_admin); + + SavingCircles implementation = _deploySavingCircles(); TransparentUpgradeableProxy proxy = _deployTransparentProxy( - address(_deploySavingCircles()), - address(_deployProxyAdmin(_admin)), - abi.encodeWithSelector(SavingCircles.initialize.selector, _admin) + address(implementation), _admin, abi.encodeWithSelector(SavingCircles.initialize.selector, _admin) ); // Deploy auxiliary contracts that reference the SavingCircles proxy new DelegatedSavingCircles(address(proxy)); new SavingCirclesViewer(address(proxy)); + address proxyAdmin = _assertDeployment(address(proxy), address(implementation), _admin); + + console2.log('Deployer', msg.sender); + console2.log('Admin', _admin); + console2.log('ProxyAdmin', proxyAdmin); + console2.log('Proxy', address(proxy)); + console2.log('Implementation', address(implementation)); + return proxy; } + + function _assertValidAdmin(address _admin) internal view { + if (_admin == address(0)) revert InvalidAdminAddress(); + if (_isProxyAdmin(_admin)) revert InvalidAdminProxyAdmin(_admin); + } + + function _assertDeployment( + address _proxy, + address _implementation, + address _expectedAdminOwner + ) internal view returns (address proxyAdmin) { + proxyAdmin = _readAddressFromSlot(_proxy, _ERC1967_ADMIN_SLOT); + if (proxyAdmin == address(0)) revert ProxyAdminNotDeployed(_proxy); + + address actualOwner = ProxyAdmin(proxyAdmin).owner(); + if (actualOwner != _expectedAdminOwner) { + revert ProxyAdminOwnerMismatch(proxyAdmin, _expectedAdminOwner, actualOwner); + } + + address actualImplementation = _readAddressFromSlot(_proxy, _ERC1967_IMPLEMENTATION_SLOT); + if (actualImplementation != _implementation) { + revert ProxyImplementationSlotMismatch(_proxy, _implementation, actualImplementation); + } + } + + function _readAddressFromSlot(address _contract, bytes32 _slot) internal view returns (address) { + return address(uint160(uint256(vm.load(_contract, _slot)))); + } + + function _isProxyAdmin(address _candidate) internal view returns (bool) { + if (_candidate.code.length == 0) return false; + + (bool ownerCallSuccess,) = _candidate.staticcall(abi.encodeWithSelector(_OWNER_SELECTOR)); + if (!ownerCallSuccess) return false; + + (bool versionCallSuccess,) = _candidate.staticcall(abi.encodeWithSelector(_UPGRADE_INTERFACE_VERSION_SELECTOR)); + return versionCallSuccess; + } } diff --git a/script/UpgradeExecute.s.sol b/script/UpgradeExecute.s.sol new file mode 100644 index 0000000..abe638e --- /dev/null +++ b/script/UpgradeExecute.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; +import {ITransparentUpgradeableProxy} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; +import {console2} from 'forge-std/console2.sol'; + +import {Common} from 'script/Common.sol'; + +contract UpgradeExecute is Common { + error NewImplementationHasNoCode(address implementation); + error NewImplementationMatchesCurrent(address implementation); + error AlreadyUpgraded(address implementation); + + function run() public { + address proxy = vm.envAddress('PROXY_ADDRESS'); + address expectedAdminOwner = vm.envAddress('EXPECTED_ADMIN_OWNER'); + address expectedCurrentImplementation = vm.envAddress('EXPECTED_CURRENT_IMPLEMENTATION'); + address newImplementation = vm.envAddress('NEW_IMPLEMENTATION'); + bytes memory upgradeCalldata = _upgradeCalldataOrEmpty(); + + address currentImplementation = _readAddressFromSlot(proxy, _ERC1967_IMPLEMENTATION_SLOT); + if (currentImplementation == newImplementation) revert AlreadyUpgraded(newImplementation); + + address proxyAdmin = _assertDeployment(proxy, expectedCurrentImplementation, expectedAdminOwner); + + if (newImplementation.code.length == 0) revert NewImplementationHasNoCode(newImplementation); + if (newImplementation == expectedCurrentImplementation) { + revert NewImplementationMatchesCurrent(newImplementation); + } + + console2.log('Executing upgrade'); + console2.log('Proxy', proxy); + console2.log('ProxyAdmin', proxyAdmin); + console2.log('AdminOwner', expectedAdminOwner); + console2.log('CurrentImplementation', currentImplementation); + console2.log('NewImplementation', newImplementation); + console2.log('CalldataLength'); + console2.log(upgradeCalldata.length); + + vm.startBroadcast(); + ProxyAdmin(proxyAdmin) + .upgradeAndCall(ITransparentUpgradeableProxy(payable(proxy)), newImplementation, upgradeCalldata); + vm.stopBroadcast(); + + _assertDeployment(proxy, newImplementation, expectedAdminOwner); + + console2.log('Upgrade successful'); + console2.log('Proxy', proxy); + console2.log('ProxyAdmin', proxyAdmin); + console2.log('CurrentImplementation', _readAddressFromSlot(proxy, _ERC1967_IMPLEMENTATION_SLOT)); + } + + function _upgradeCalldataOrEmpty() internal view returns (bytes memory data) { + try vm.envBytes('UPGRADE_CALLDATA') returns (bytes memory envData) { + return envData; + } catch { + return bytes(''); + } + } +} diff --git a/script/UpgradePostValidate.s.sol b/script/UpgradePostValidate.s.sol new file mode 100644 index 0000000..6526399 --- /dev/null +++ b/script/UpgradePostValidate.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; +import {console2} from 'forge-std/console2.sol'; + +import {Common} from 'script/Common.sol'; +import {SavingCircles} from 'src/contracts/SavingCircles.sol'; +import {SavingCirclesViewer} from 'src/contracts/SavingCirclesViewer.sol'; + +contract UpgradePostValidate is Common { + error ViewerProxyMismatch(address viewer, address expectedProxy, address actualProxy); + + function run() public view { + address proxy = vm.envAddress('PROXY_ADDRESS'); + address expectedAdminOwner = vm.envAddress('EXPECTED_ADMIN_OWNER'); + address expectedImplementation = vm.envAddress('EXPECTED_IMPLEMENTATION'); + + address proxyAdmin = _assertDeployment(proxy, expectedImplementation, expectedAdminOwner); + uint256 nextId = SavingCircles(proxy).nextId(); + + console2.log('Post-upgrade validation successful'); + console2.log('Proxy', proxy); + console2.log('ProxyAdmin', proxyAdmin); + console2.log('AdminOwner', ProxyAdmin(proxyAdmin).owner()); + console2.log('Implementation', expectedImplementation); + console2.log('SmokeCheck.nextId'); + console2.log(nextId); + + address viewer = _viewerAddressOrZero(); + if (viewer == address(0)) return; + + address viewerProxy = address(SavingCirclesViewer(viewer).SAVING_CIRCLES()); + if (viewerProxy != proxy) revert ViewerProxyMismatch(viewer, proxy, viewerProxy); + + uint256 totalBalance = SavingCirclesViewer(viewer).getTotalBalance(expectedAdminOwner); + console2.log('Viewer', viewer); + console2.log('Viewer.SAVING_CIRCLES', viewerProxy); + console2.log('ViewerSmoke.totalBalance(owner)'); + console2.log(totalBalance); + } + + function _viewerAddressOrZero() internal view returns (address viewer) { + try vm.envAddress('VIEWER_ADDRESS') returns (address envViewer) { + return envViewer; + } catch { + return address(0); + } + } +} diff --git a/script/UpgradeValidate.s.sol b/script/UpgradeValidate.s.sol new file mode 100644 index 0000000..e968b41 --- /dev/null +++ b/script/UpgradeValidate.s.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; +import {console2} from 'forge-std/console2.sol'; + +import {Common} from 'script/Common.sol'; +import {SavingCircles} from 'src/contracts/SavingCircles.sol'; + +contract UpgradeValidate is Common { + error NewImplementationHasNoCode(address implementation); + error NewImplementationMatchesCurrent(address implementation); + + function run() public view { + address proxy = vm.envAddress('PROXY_ADDRESS'); + address expectedAdminOwner = vm.envAddress('EXPECTED_ADMIN_OWNER'); + address expectedCurrentImplementation = vm.envAddress('EXPECTED_CURRENT_IMPLEMENTATION'); + address newImplementation = vm.envAddress('NEW_IMPLEMENTATION'); + + address proxyAdmin = _assertDeployment(proxy, expectedCurrentImplementation, expectedAdminOwner); + + if (newImplementation.code.length == 0) revert NewImplementationHasNoCode(newImplementation); + if (newImplementation == expectedCurrentImplementation) { + revert NewImplementationMatchesCurrent(newImplementation); + } + + // Lightweight smoke check against proxy to confirm implementation responds. + uint256 nextId = SavingCircles(proxy).nextId(); + + console2.log('Validation successful'); + console2.log('Proxy', proxy); + console2.log('ProxyAdmin', proxyAdmin); + console2.log('AdminOwner', ProxyAdmin(proxyAdmin).owner()); + console2.log('CurrentImplementation', expectedCurrentImplementation); + console2.log('NewImplementation', newImplementation); + console2.log('SmokeCheck.nextId'); + console2.log(nextId); + } +} diff --git a/scripts/check_upgrade_safety.sh b/scripts/check_upgrade_safety.sh new file mode 100755 index 0000000..e917e6a --- /dev/null +++ b/scripts/check_upgrade_safety.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +OLD_REF="${1:-origin/dev}" +NEW_REF="${2:-HEAD}" +CONTRACT_TARGET="${3:-src/contracts/SavingCircles.sol:SavingCircles}" + +ROOT_DIR="$(git rev-parse --show-toplevel)" +TMP_DIR="$(mktemp -d)" +OLD_DIR="$TMP_DIR/old" +NEW_DIR="$TMP_DIR/new" + +cleanup() { + git -C "$ROOT_DIR" worktree remove "$OLD_DIR" --force >/dev/null 2>&1 || true + git -C "$ROOT_DIR" worktree remove "$NEW_DIR" --force >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Missing required command: $cmd" + exit 1 + fi +} + +ensure_node_modules_link() { + local target_dir="$1" + if [[ -d "$ROOT_DIR/node_modules" && ! -e "$target_dir/node_modules" ]]; then + ln -s "$ROOT_DIR/node_modules" "$target_dir/node_modules" + fi +} + +prepare_worktree() { + local ref="$1" + local dir="$2" + + git -C "$ROOT_DIR" worktree add --detach "$dir" "$ref" >/dev/null + + if [[ -f "$dir/.gitmodules" ]]; then + git -C "$dir" submodule sync --recursive >/dev/null + git -C "$dir" submodule update --init --recursive >/dev/null + fi + + ensure_node_modules_link "$dir" +} + +build_info_dir_for() { + local workdir="$1" + local out_dir + + out_dir="$(cd "$workdir" && forge config --json | jq -r '.out // "out"')" + echo "$workdir/$out_dir/build-info" +} + +compile_for_validation() { + local workdir="$1" + local label="$2" + + echo "Compiling $label at $workdir ..." + ( + cd "$workdir" + forge clean + forge build --build-info --extra-output storageLayout + ) >/dev/null + + local build_info_dir + build_info_dir="$(build_info_dir_for "$workdir")" + + if [[ ! -d "$build_info_dir" ]]; then + echo "Build info directory not found for $label: $build_info_dir" + echo "Make sure foundry.toml enables build_info." + exit 1 + fi + + if ! find "$build_info_dir" -type f -name '*.json' | grep -q .; then + echo "No build info JSON files found for $label in $build_info_dir" + exit 1 + fi +} + +echo "Running OpenZeppelin upgrade safety validation for $CONTRACT_TARGET" +echo "Old ref: $OLD_REF" +echo "New ref: $NEW_REF" + +require_cmd git +require_cmd jq +require_cmd forge +require_cmd npx + +prepare_worktree "$OLD_REF" "$OLD_DIR" +compile_for_validation "$OLD_DIR" "$OLD_REF" + +if [[ "$NEW_REF" == "HEAD" ]]; then + compile_for_validation "$ROOT_DIR" "$NEW_REF" + NEW_BUILD_INFO_DIR="$(build_info_dir_for "$ROOT_DIR")" +else + prepare_worktree "$NEW_REF" "$NEW_DIR" + compile_for_validation "$NEW_DIR" "$NEW_REF" + NEW_BUILD_INFO_DIR="$(build_info_dir_for "$NEW_DIR")" +fi + +OLD_BUILD_INFO_DIR="$(build_info_dir_for "$OLD_DIR")" + +OLD_REF_BUILD_INFO_LINK="$TMP_DIR/old-build-info" +ln -s "$OLD_BUILD_INFO_DIR" "$OLD_REF_BUILD_INFO_LINK" + +echo "Validating upgrade safety with @openzeppelin/upgrades-core ..." +OZ_UPGRADES_SILENCE_WARNINGS=true npx --yes @openzeppelin/upgrades-core validate "$NEW_BUILD_INFO_DIR" \ + --contract "$CONTRACT_TARGET" \ + --reference "old-build-info:$CONTRACT_TARGET" \ + --referenceBuildInfoDirs "$OLD_REF_BUILD_INFO_LINK" \ + --requireReference + +echo "Upgrade safety check passed for $CONTRACT_TARGET"