Skip to main content
A proxy deployment consists of two contracts: the proxy, which delegates every call, and the implementation, which holds the logic. Both must be verified separately. For any proxy that follows ERC-1967, the Tenderly explorer reads the standard implementation storage slot, so once both contracts are verified, calls to the proxy address are decoded with the implementation’s ABI. This guide deploys a UUPS implementation (CounterV1) behind an ERC1967Proxy on a Virtual Environment, verifies both in one forge script run, upgrades the implementation to CounterV2, and covers re-verifying a proxy that was deployed without verification. For the Hardhat equivalent, see Verifying Proxy Contracts.

Before you begin

  • Install OpenZeppelin’s upgradeable contracts alongside the base library:
  • Add both remappings to remappings.txt:
remappings.txt
The two libraries serve different roles: openzeppelin-contracts provides the proxy (ERC1967Proxy), and openzeppelin-contracts-upgradeable provides the implementation building blocks (Initializable, UUPSUpgradeable, OwnableUpgradeable). The upgradeable versions replace constructors with initializer functions, because constructors don’t run when state lives behind a proxy.

The implementation contract

src/CounterV1.sol
Three parts of this contract matter for the proxy pattern:
  1. constructor() { _disableInitializers(); } locks the implementation. Only the proxy is supposed to hold state; this constructor makes a direct initialize call on the implementation address revert.
  2. initialize(...) replaces the constructor. The proxy delegatecalls it once, in the same transaction that deploys the proxy.
  3. _authorizeUpgrade(...) is the required UUPS permissioning hook. It runs on every upgradeToAndCall; if it reverts, the upgrade is blocked.

The deploy script

script/DeployProxy.s.sol
The script deploys the logic contract, builds the initialize calldata with abi.encodeCall (which type-checks the arguments at compile time, unlike abi.encodeWithSignature), and deploys the proxy. The proxy’s constructor stores the implementation address in the ERC-1967 implementation slot and delegatecalls initData against it, atomically initializing the proxy’s storage.

Deploy and verify both contracts

The proxy and the implementation are two contracts in one script, so the command is the same forge script shape as any multi-contract deployment:
showLineNumbers
Expected tail of the output:
Note the second submission path: the proxy is verified as lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy, the OpenZeppelin contract from your lib/ tree, not a contract in src/. Foundry resolves this automatically because the script’s broadcast log records every new and its bytecode.

Confirm the proxy linkage

The explorer decodes calls through the proxy when both the proxy and the implementation are verified. To confirm the proxy is delegating, read the ERC-1967 implementation slot directly:
showLineNumbers
Then call an implementation function through the proxy:
In the Tenderly Dashboard, the proxy’s address page shows the verified ERC1967Proxy source, a proxy tag with the linked implementation address, and an ABI view that includes the implementation’s functions. Transactions calling proxy.increment() or proxy.setNumber(...) are decoded with the implementation’s function names and parameter labels in the call trace.
If the explorer shows the proxy but doesn’t decode calls with the implementation’s ABI, the most common cause is that only one of the two contracts is verified. Re-run forge verify-contract on whichever is missing.

Upgrade the implementation

UUPS upgrades are issued through upgradeToAndCall(newImpl, callData), inherited from UUPSUpgradeable. Add a V2:
src/CounterV2.sol
Storage layout constraints when writing a V2:
  • Inherit from V1, or copy V1’s storage layout verbatim. Storage slots are positional; adding a new state variable before an existing one corrupts the proxy’s state.
  • New state variables go after V1’s existing variables, never between them.
  • Don’t change existing variable types or reorder declarations.
script/UpgradeProxy.s.sol
Run it with the same verify flags:
showLineNumbers
Only CounterV2 is deployed and verified (the output reports (1) contracts). The proxy address doesn’t change; only its ERC-1967 slot does. After the script returns:
showLineNumbers
The proxy address in the explorer now decodes against CounterV2’s ABI. The old CounterV1 implementation remains verified at its original address but is no longer the active implementation.

Verify a proxy that’s already deployed

If you deployed without --verify, run forge verify-contract for each contract separately. The implementation was deployed via new CounterV1() with no constructor arguments, so no --constructor-args flag is needed:
showLineNumbers
The proxy requires the exact constructor arguments used at deploy time, ABI-encoded:
showLineNumbers
Three details to get right:
  1. The contract path uses the lib/ location, not src/. The proxy is OpenZeppelin’s code, so the source the verifier matches against lives in lib/openzeppelin-contracts/.... forge script --verify resolves this automatically; forge verify-contract doesn’t.
  2. initData must match the deploy-time initData exactly. Even if state changed after deployment, the constructor argument is the original initialize(owner, 42) calldata the proxy bytecode was constructed with.
  3. The proxy’s constructor signature is (address, bytes), and forge verify-contract expects the already-ABI-encoded form, which is what cast abi-encode produces.

Next steps