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

export TENDERLY_VIRTUAL_TESTNET_RPC=...   # your Virtual Environment RPC URL
export TENDERLY_VERIFIER_URL=$TENDERLY_VIRTUAL_TESTNET_RPC/verify
export PRIVATE_KEY=...                    # deployer private key
  • Install OpenZeppelin’s upgradeable contracts alongside the base library:
forge install OpenZeppelin/openzeppelin-contracts --no-git --shallow
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-git --shallow
  • Add both remappings to remappings.txt:
remappings.txt
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
forge-std/=lib/forge-std/src/
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public number;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address owner_, uint256 initialNumber) external initializer {
        __Ownable_init(owner_);
        number = initialNumber;
    }

    function increment() external {
        number += 1;
    }

    function setNumber(uint256 newNumber) external {
        number = newNumber;
    }

    function version() external pure virtual returns (string memory) {
        return "v1";
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Script, console} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {CounterV1} from "../src/CounterV1.sol";

contract DeployProxyScript is Script {
    function run() public {
        address owner = vm.addr(vm.envUint("PRIVATE_KEY"));
        uint256 initialNumber = 42;

        vm.startBroadcast();

        // 1. Deploy the implementation.
        CounterV1 implementation = new CounterV1();

        // 2. Deploy the proxy, calling initialize(owner, 42) in the same tx.
        bytes memory initData = abi.encodeCall(CounterV1.initialize, (owner, initialNumber));
        ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);

        vm.stopBroadcast();

        console.log("Implementation:", address(implementation));
        console.log("Proxy         :", address(proxy));
        console.log("Owner         :", owner);
    }
}
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
forge script script/DeployProxy.s.sol:DeployProxyScript \
  --rpc-url        $TENDERLY_VIRTUAL_TESTNET_RPC \
  --private-key    $PRIVATE_KEY \
  --broadcast --slow \
  --verify \
  --verifier       custom \
  --verifier-url   $TENDERLY_VERIFIER_URL
Expected tail of the output:
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
##
Start verification for (2) contracts
Submitting verification for [src/CounterV1.sol:CounterV1] 0xeDa7...
	Response: `OK`
	Details: `Pass - Verified`
Submitting verification for [lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy] 0xb22e...
	Response: `OK`
	Details: `Pass - Verified`
All (2) contracts were verified!
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
PROXY=<your-proxy-address>
IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

cast storage $PROXY $IMPL_SLOT --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 0x000000000000000000000000<implementation address, 20 bytes>
Then call an implementation function through the proxy:
cast call $PROXY "number()(uint256)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 42
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {CounterV1} from "./CounterV1.sol";

contract CounterV2 is CounterV1 {
    function decrement() external {
        number -= 1;
    }

    function version() external pure override returns (string memory) {
        return "v2";
    }
}
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Script, console} from "forge-std/Script.sol";
import {CounterV1} from "../src/CounterV1.sol";
import {CounterV2} from "../src/CounterV2.sol";

contract UpgradeProxyScript is Script {
    function run() public {
        address proxy = vm.envAddress("PROXY");

        vm.startBroadcast();

        CounterV2 newImplementation = new CounterV2();
        CounterV1(proxy).upgradeToAndCall(address(newImplementation), bytes(""));

        vm.stopBroadcast();

        console.log("New implementation:", address(newImplementation));
        console.log("Proxy upgraded:    ", proxy);
    }
}
Run it with the same verify flags:
showLineNumbers
PROXY=<your-proxy-address> \
forge script script/UpgradeProxy.s.sol:UpgradeProxyScript \
  --rpc-url        $TENDERLY_VIRTUAL_TESTNET_RPC \
  --private-key    $PRIVATE_KEY \
  --broadcast --slow \
  --verify \
  --verifier       custom \
  --verifier-url   $TENDERLY_VERIFIER_URL
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
cast storage $PROXY $IMPL_SLOT --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 0x000000000000000000000000<CounterV2 address>

cast call $PROXY "version()(string)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# "v2"

cast call $PROXY "number()(uint256)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 42   <-- state preserved; only the logic changed
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
forge verify-contract <IMPL_ADDRESS> src/CounterV1.sol:CounterV1 \
  --verifier       custom \
  --verifier-url   $TENDERLY_VERIFIER_URL \
  --watch
The proxy requires the exact constructor arguments used at deploy time, ABI-encoded:
showLineNumbers
# ABI-encode: constructor(address implementation, bytes initData)
INIT_DATA=$(cast calldata "initialize(address,uint256)" <OWNER_ADDRESS> 42)

forge verify-contract <PROXY_ADDRESS> \
  lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \
  --verifier       custom \
  --verifier-url   $TENDERLY_VERIFIER_URL \
  --constructor-args $(cast abi-encode "constructor(address,bytes)" <IMPL_ADDRESS> $INIT_DATA) \
  --watch
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