Upgradeable ERC20 stablecoin using the UUPS proxy pattern with OpenZeppelin and Foundry.
The contract uses UUPS (ERC-1822) proxies. Only the ADMIN_ROLE holder can authorize upgrades. Upgrades work even when the contract is paused.
- Foundry installed
- Node.js installed (required for OZ storage layout validation via FFI)
- The admin private key for the deployed proxy
Place the new contract in src/upgrades/ (e.g. src/upgrades/StablecoinV2.sol). It must:
- Inherit from the previous version and only append new storage variables at the end. Never reorder, rename, or remove existing storage.
- Use
reinitializer(n)(notinitializer) for the upgrade init function, wherenis the next version number. - Include the
@custom:oz-upgrades-fromannotation pointing to the previous contract name. This enables automatic storage layout validation.
Example:
/// @custom:oz-upgrades-from Stablecoin
contract StablecoinV2 is Stablecoin {
uint256 private _maxSupply; // new storage appended at the end
function initializeV2(uint256 maxSupply_) public reinitializer(2) {
_maxSupply = maxSupply_;
}
}Always start by running the upgrade test suite against a clean build:
forge clean && forge test --match-contract UpgradeStablecoin -vvvThis runs storage layout validation and verifies that all existing state (balances, roles, minter allowances, frozen accounts, pause state) is preserved through the upgrade.
The upgrade scripts accept pre-encoded bytes for the reinitializer call. Use cast calldata to encode whatever reinitializer your new version needs:
# V1→V2: initializeV2(uint256 maxSupply)
MAX_SUPPLY=$(cast parse-units 1000000 6) # 1000000000000 (6-decimal token)
REINIT_DATA=$(cast calldata "initializeV2(uint256)" $MAX_SUPPLY)
# V2→V3 (hypothetical): initializeV3(uint256 newParam, bool flag)
REINIT_DATA=$(cast calldata "initializeV3(uint256,bool)" 42 true)Before broadcasting, simulate the full upgrade against a fork of the live network. This uses vm.prank to impersonate the admin without needing the private key:
REINIT_DATA=$(cast calldata "initializeV2(uint256)" $MAX_SUPPLY)
forge clean && forge script script/SimulateUpgrade.s.sol \
--fork-url $RPC_URL \
--sig 'run(address,string,bytes,address,string)' \
$PROXY "StablecoinV2.sol" $REINIT_DATA $ADMIN "Stablecoin.sol"| Parameter | Description |
|---|---|
$PROXY |
Address of the deployed proxy contract |
"StablecoinV2.sol" |
Filename of the new implementation in src/upgrades/ |
$REINIT_DATA |
ABI-encoded reinitializer calldata (see Encoding Reinitializer Calldata) |
$ADMIN |
Address that holds ADMIN_ROLE on the proxy |
"Stablecoin.sol" |
Reference contract for storage layout validation (the contract currently behind the proxy) |
The simulation will:
- Snapshot all on-chain state (name, symbol, decimals, supply, minters, frozen accounts, roles)
- Validate storage layout compatibility
- Execute the upgrade via
vm.prank(admin) - Assert every snapshotted value is unchanged
If it prints === SIMULATION PASSED ===, the upgrade is safe.
You can perform the upgrade in 1 step (script handles everything) or 2 steps (deploy implementation first, then upgrade the proxy separately).
The UpgradeStablecoin.s.sol script deploys the new implementation and calls upgradeToAndCall on the proxy in a single execution. It also runs storage layout validation and logs pre/post state.
Dry run (omit --broadcast to verify without submitting):
REINIT_DATA=$(cast calldata "initializeV2(uint256)" $MAX_SUPPLY)
forge clean && forge script script/UpgradeStablecoin.s.sol \
--fork-url $RPC_URL \
--private-key $ADMIN_KEY \
--sig 'run(address,string,bytes,string)' \
$PROXY "StablecoinV2.sol" $REINIT_DATA "Stablecoin.sol"Broadcast (add --broadcast to send the transactions):
forge clean && forge script script/UpgradeStablecoin.s.sol \
--broadcast --private-key $ADMIN_KEY --rpc-url $RPC_URL \
--sig 'run(address,string,bytes,string)' \
$PROXY "StablecoinV2.sol" $REINIT_DATA "Stablecoin.sol"Transaction receipts are saved to broadcast/.
Use this when you want to inspect the deployed implementation before upgrading, or when the deployer and the proxy admin are different parties (e.g., a multisig holds ADMIN_ROLE).
Step 1 — Deploy the new implementation:
forge clean && forge script script/DeployNewImplementation.s.sol \
--broadcast --private-key $DEPLOYER_KEY --rpc-url $RPC_URL \
--sig 'run(string,string)' "StablecoinV2.sol" "Stablecoin.sol"This validates storage layout, deploys the new implementation, and outputs its address. Save it:
NEW_IMPL=<address from output>Step 2 — Encode the reinitializer calldata and upgrade the proxy:
# Encode initializeV2(uint256) calldata
REINIT_DATA=$(cast calldata "initializeV2(uint256)" $MAX_SUPPLY)
# Call upgradeToAndCall on the proxy (must be sent by the ADMIN_ROLE holder)
cast send $PROXY "upgradeToAndCall(address,bytes)" $NEW_IMPL $REINIT_DATA \
--private-key $ADMIN_KEY \
--rpc-url $RPC_URLVerify the upgrade succeeded:
# Check the implementation address behind the proxy (EIP-1967 slot)
cast implementation $PROXY --rpc-url $RPC_URL
# Confirm new functionality is accessible
cast call $PROXY "version()(string)" --rpc-url $RPC_URL
cast call $PROXY "maxSupply()(uint256)" --rpc-url $RPC_URLThe scripts are version-agnostic. For a V2→V3 upgrade, the only things that change are the contract name, reinit data, and reference contract:
# The reference contract is now StablecoinV2.sol (the version currently deployed)
REINIT_DATA=$(cast calldata "initializeV3(uint256,bool)" $PARAM1 true)
forge clean && forge script script/UpgradeStablecoin.s.sol \
--broadcast --private-key $ADMIN_KEY --rpc-url $RPC_URL \
--sig 'run(address,string,bytes,string)' \
$PROXY "StablecoinV3.sol" $REINIT_DATA "StablecoinV2.sol"- Always run
forge cleanbefore any script or test. Stale build artifacts inout/can cause storage layout validation to produce incorrect results silently. - Upgrades work while paused. The
_authorizeUpgradefunction checks onlyADMIN_ROLE, notwhenNotPaused. This is intentional so the admin can upgrade during an emergency pause. - The reinitializer can only run once. After the upgrade, calling
initializeV2again will revert withInvalidInitialization(). The originalinitializeis also permanently locked. - Storage layout is validated automatically by the scripts via the
referenceContractparameter. This compares the new contract's layout against the specified reference and reverts on any incompatibility (reordered slots, type changes, etc.).
forge clean && forge script script/DeployStablecoin.s.sol \
--broadcast --private-key $ADMIN_KEY --rpc-url $RPC_URL \
--sig 'run(string,string,uint8,address,address,address,address)' \
$NAME $SYMBOL $DECIMALS $ADMIN $BURNER $PAUSER $FREEZERThis deploys the UUPS proxy with Stablecoin.sol as the initial implementation.